Ship Novya PWA with backend for deployable production use.
Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* One-shot recovery: copy one password user (and that user's graph)
|
||||
* from novya_test into live novya. Does not copy auth_sessions.
|
||||
* Does not print password hashes or API keys.
|
||||
*/
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
const SOURCE_URL = 'mysql://novya:novya@127.0.0.1:3306/novya_test';
|
||||
const DEST_URL = 'mysql://novya:novya@127.0.0.1:3306/novya';
|
||||
const TARGET_EMAIL = 'ahmadreza6137@gmail.com';
|
||||
|
||||
function client(url: string) {
|
||||
return new PrismaClient({
|
||||
datasources: { db: { url } },
|
||||
log: ['error'],
|
||||
});
|
||||
}
|
||||
|
||||
function jsonValue(value: Prisma.JsonValue | null | undefined): Prisma.InputJsonValue | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return value as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const source = client(SOURCE_URL);
|
||||
const dest = client(DEST_URL);
|
||||
|
||||
try {
|
||||
const identity = await source.authIdentity.findFirst({
|
||||
where: {
|
||||
provider: 'password',
|
||||
email: TARGET_EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(`Source password identity not found for target email`);
|
||||
}
|
||||
|
||||
const sourceUser = await source.user.findUniqueOrThrow({
|
||||
where: { id: identity.userId },
|
||||
include: {
|
||||
profile: true,
|
||||
identities: true,
|
||||
journey: true,
|
||||
assessmentAttempts: {
|
||||
include: { answers: true, scores: true },
|
||||
},
|
||||
sessionProgress: true,
|
||||
chatSessions: {
|
||||
include: { messages: true },
|
||||
},
|
||||
aiRuns: true,
|
||||
},
|
||||
});
|
||||
|
||||
const destEmailClash = await dest.authIdentity.findFirst({
|
||||
where: {
|
||||
provider: 'password',
|
||||
email: TARGET_EMAIL,
|
||||
},
|
||||
});
|
||||
if (destEmailClash) {
|
||||
throw new Error('Target email already exists in novya — aborting to avoid overwrite');
|
||||
}
|
||||
|
||||
const destExternalClash = await dest.user.findUnique({
|
||||
where: { externalId: sourceUser.externalId },
|
||||
});
|
||||
if (destExternalClash) {
|
||||
throw new Error('externalId already exists in novya — aborting to avoid overwrite');
|
||||
}
|
||||
|
||||
const summary = await dest.$transaction(
|
||||
async (tx) => {
|
||||
const createdUser = await tx.user.create({
|
||||
data: {
|
||||
externalId: sourceUser.externalId,
|
||||
isGuest: sourceUser.isGuest,
|
||||
createdAt: sourceUser.createdAt,
|
||||
updatedAt: sourceUser.updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (sourceUser.profile) {
|
||||
await tx.userProfile.create({
|
||||
data: {
|
||||
userId: createdUser.id,
|
||||
name: sourceUser.profile.name,
|
||||
age: sourceUser.profile.age,
|
||||
createdAt: sourceUser.profile.createdAt,
|
||||
updatedAt: sourceUser.profile.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of sourceUser.identities) {
|
||||
await tx.authIdentity.create({
|
||||
data: {
|
||||
userId: createdUser.id,
|
||||
provider: item.provider,
|
||||
providerSubject: item.providerSubject,
|
||||
email: item.email,
|
||||
passwordHash: item.passwordHash,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceUser.journey) {
|
||||
await tx.learningJourney.create({
|
||||
data: {
|
||||
userId: createdUser.id,
|
||||
currentStep: sourceUser.journey.currentStep,
|
||||
preTestCompletedAt: sourceUser.journey.preTestCompletedAt,
|
||||
trainingStartedAt: sourceUser.journey.trainingStartedAt,
|
||||
trainingCompletedAt: sourceUser.journey.trainingCompletedAt,
|
||||
postTestCompletedAt: sourceUser.journey.postTestCompletedAt,
|
||||
lastActivityAt: sourceUser.journey.lastActivityAt,
|
||||
createdAt: sourceUser.journey.createdAt,
|
||||
updatedAt: sourceUser.journey.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of sourceUser.sessionProgress) {
|
||||
await tx.sessionProgress.create({
|
||||
data: {
|
||||
userId: createdUser.id,
|
||||
sessionOrder: row.sessionOrder,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
completedAt: row.completedAt,
|
||||
completionMetadata: jsonValue(row.completionMetadata),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const attemptIdMap = new Map<bigint, bigint>();
|
||||
for (const attempt of sourceUser.assessmentAttempts) {
|
||||
const createdAttempt = await tx.assessmentAttempt.create({
|
||||
data: {
|
||||
userId: createdUser.id,
|
||||
phase: attempt.phase,
|
||||
status: attempt.status,
|
||||
submittedAt: attempt.submittedAt,
|
||||
scoredAt: attempt.scoredAt,
|
||||
createdAt: attempt.createdAt,
|
||||
updatedAt: attempt.updatedAt,
|
||||
answers: {
|
||||
create: attempt.answers.map((answer) => ({
|
||||
questionId: answer.questionId,
|
||||
rawValue: answer.rawValue,
|
||||
points: answer.points,
|
||||
dimension: answer.dimension,
|
||||
isReverseScored: answer.isReverseScored,
|
||||
createdAt: answer.createdAt,
|
||||
})),
|
||||
},
|
||||
scores: {
|
||||
create: attempt.scores.map((score) => ({
|
||||
metricKey: score.metricKey,
|
||||
rawScore: score.rawScore,
|
||||
createdAt: score.createdAt,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
attemptIdMap.set(attempt.id, createdAttempt.id);
|
||||
}
|
||||
|
||||
const chatIdMap = new Map<bigint, bigint>();
|
||||
for (const session of sourceUser.chatSessions) {
|
||||
const createdSession = await tx.chatSession.create({
|
||||
data: {
|
||||
userId: createdUser.id,
|
||||
sessionOrder: session.sessionOrder,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: session.endedAt,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
},
|
||||
});
|
||||
chatIdMap.set(session.id, createdSession.id);
|
||||
|
||||
if (session.messages.length > 0) {
|
||||
await tx.chatMessage.createMany({
|
||||
data: session.messages.map((message) => ({
|
||||
chatSessionId: createdSession.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
metadata: jsonValue(message.metadata) ?? Prisma.JsonNull,
|
||||
createdAt: message.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const run of sourceUser.aiRuns) {
|
||||
const mappedChatId = run.chatSessionId ? chatIdMap.get(run.chatSessionId) : undefined;
|
||||
await tx.aiAnalysisRun.create({
|
||||
data: {
|
||||
userId: createdUser.id,
|
||||
chatSessionId: mappedChatId ?? null,
|
||||
provider: run.provider,
|
||||
model: run.model,
|
||||
status: run.status,
|
||||
latencyMs: run.latencyMs,
|
||||
totalTokens: run.totalTokens,
|
||||
errorMessage: run.errorMessage,
|
||||
createdAt: run.createdAt,
|
||||
updatedAt: run.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const destIdentity = await tx.authIdentity.findFirstOrThrow({
|
||||
where: {
|
||||
userId: createdUser.id,
|
||||
provider: 'password',
|
||||
email: TARGET_EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
const hashCopied =
|
||||
Boolean(identity.passwordHash) && destIdentity.passwordHash === identity.passwordHash;
|
||||
const hashIsArgon2 = Boolean(destIdentity.passwordHash?.startsWith('$argon2id$'));
|
||||
|
||||
return {
|
||||
newUserId: createdUser.id.toString(),
|
||||
externalId: createdUser.externalId,
|
||||
profile: Boolean(sourceUser.profile),
|
||||
identities: sourceUser.identities.length,
|
||||
authSessionsCopied: 0,
|
||||
journeyStep: sourceUser.journey?.currentStep ?? null,
|
||||
preTestAt: sourceUser.journey?.preTestCompletedAt?.toISOString() ?? null,
|
||||
attempts: sourceUser.assessmentAttempts.length,
|
||||
answers: sourceUser.assessmentAttempts.reduce((n, a) => n + a.answers.length, 0),
|
||||
scores: sourceUser.assessmentAttempts.reduce((n, a) => n + a.scores.length, 0),
|
||||
progressRows: sourceUser.sessionProgress.length,
|
||||
chatSessions: sourceUser.chatSessions.length,
|
||||
chatMessages: sourceUser.chatSessions.reduce((n, s) => n + s.messages.length, 0),
|
||||
aiRuns: sourceUser.aiRuns.length,
|
||||
hashCopied,
|
||||
hashIsArgon2,
|
||||
attemptIdRemaps: attemptIdMap.size,
|
||||
chatIdRemaps: chatIdMap.size,
|
||||
};
|
||||
},
|
||||
{ maxWait: 15_000, timeout: 60_000 },
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, email: TARGET_EMAIL, ...summary }, null, 2));
|
||||
} finally {
|
||||
await source.$disconnect();
|
||||
await dest.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Verify recovered user in live novya. Issues a NEW session (does not copy old ones).
|
||||
* Does not print tokens, hashes, or message content.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateSessionToken, hashSessionToken } from '../src/modules/auth/sessionToken.ts';
|
||||
|
||||
const DEST_URL = 'mysql://novya:novya@127.0.0.1:3306/novya';
|
||||
const SOURCE_URL = 'mysql://novya:novya@127.0.0.1:3306/novya_test';
|
||||
const TARGET_EMAIL = 'ahmadreza6137@gmail.com';
|
||||
const API = 'http://localhost:3001';
|
||||
|
||||
async function requestJson(path: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`${API}${path}`, options);
|
||||
const raw = await response.text();
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
body = raw;
|
||||
}
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
function dataOf(body: unknown): Record<string, unknown> | null {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const record = body as Record<string, unknown>;
|
||||
if (record.data && typeof record.data === 'object') {
|
||||
return record.data as Record<string, unknown>;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dest = new PrismaClient({ datasources: { db: { url: DEST_URL } }, log: ['error'] });
|
||||
const source = new PrismaClient({ datasources: { db: { url: SOURCE_URL } }, log: ['error'] });
|
||||
|
||||
try {
|
||||
const destIdentity = await dest.authIdentity.findFirst({
|
||||
where: { provider: 'password', email: TARGET_EMAIL },
|
||||
include: { user: { include: { profile: true, journey: true } } },
|
||||
});
|
||||
const sourceIdentity = await source.authIdentity.findFirst({
|
||||
where: { provider: 'password', email: TARGET_EMAIL },
|
||||
});
|
||||
|
||||
if (!destIdentity || !sourceIdentity) {
|
||||
throw new Error('Recovered identity missing');
|
||||
}
|
||||
|
||||
const hashMatch = destIdentity.passwordHash === sourceIdentity.passwordHash;
|
||||
const chatSessions = await dest.chatSession.findMany({
|
||||
where: { userId: destIdentity.userId },
|
||||
orderBy: { startedAt: 'asc' },
|
||||
select: { id: true, sessionOrder: true, _count: { select: { messages: true } } },
|
||||
});
|
||||
const progress = await dest.sessionProgress.findMany({
|
||||
where: { userId: destIdentity.userId },
|
||||
select: { sessionOrder: true, status: true },
|
||||
});
|
||||
const attempts = await dest.assessmentAttempt.count({
|
||||
where: { userId: destIdentity.userId, phase: 'pre' },
|
||||
});
|
||||
const userIsGuest = destIdentity.user.isGuest;
|
||||
|
||||
const token = generateSessionToken();
|
||||
await dest.authSession.create({
|
||||
data: {
|
||||
userId: destIdentity.userId,
|
||||
tokenHash: hashSessionToken(token),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const auth = { Authorization: `Bearer ${token}` };
|
||||
|
||||
const loginWrong = await requestJson('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: TARGET_EMAIL, password: 'definitely-not-the-password' }),
|
||||
});
|
||||
|
||||
const loginPassword = process.env.RECOVERY_LOGIN_PASSWORD;
|
||||
let loginOk: { status: number; code: string | null } | null = null;
|
||||
if (loginPassword) {
|
||||
const loginRes = await requestJson('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: TARGET_EMAIL, password: loginPassword }),
|
||||
});
|
||||
loginOk = {
|
||||
status: loginRes.status,
|
||||
code: ((loginRes.body as { error?: { code?: string } } | null)?.error?.code) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const me = await requestJson('/auth/me', { headers: auth });
|
||||
const usersMe = await requestJson('/users/me', { headers: auth });
|
||||
const latestPre = await requestJson('/assessments/attempts/latest?phase=pre', { headers: auth });
|
||||
const journey = await requestJson('/journey', { headers: auth });
|
||||
const sessionProgress = await requestJson('/sessions/progress', { headers: auth });
|
||||
|
||||
const richestChat = [...chatSessions].sort((a, b) => b._count.messages - a._count.messages)[0];
|
||||
const firstChatId = richestChat?.id.toString();
|
||||
const chatMessages = firstChatId
|
||||
? await requestJson(`/chat/sessions/${firstChatId}/messages`, { headers: auth })
|
||||
: { status: 0, body: null };
|
||||
|
||||
const meData = dataOf(me.body);
|
||||
const usersData = dataOf(usersMe.body);
|
||||
const journeyData = dataOf(journey.body);
|
||||
const progressData = dataOf(sessionProgress.body);
|
||||
const chatData = dataOf(chatMessages.body);
|
||||
const latestData = dataOf(latestPre.body);
|
||||
const chatOwned =
|
||||
Boolean(firstChatId) &&
|
||||
Array.isArray((chatData as { messages?: unknown[] } | null)?.messages) &&
|
||||
((chatData as { messages: unknown[] }).messages.length > 0);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
email: TARGET_EMAIL,
|
||||
externalId: destIdentity.user.externalId,
|
||||
hashMatch,
|
||||
hashIsArgon2: Boolean(destIdentity.passwordHash?.startsWith('$argon2id$')),
|
||||
profilePresent: Boolean(destIdentity.user.profile),
|
||||
preAttempts: attempts,
|
||||
progress,
|
||||
chatSessions: chatSessions.map((row) => ({
|
||||
id: row.id.toString(),
|
||||
sessionOrder: row.sessionOrder,
|
||||
messageCount: row._count.messages,
|
||||
})),
|
||||
loginWrongStatus: loginWrong.status,
|
||||
loginWithProvidedPassword: loginOk,
|
||||
userIsGuest,
|
||||
meStatus: me.status,
|
||||
meEmailMatches: meData?.email === TARGET_EMAIL,
|
||||
meIsRegistered: meData?.isGuest === false,
|
||||
meProfileComplete: meData?.profileComplete === true,
|
||||
usersMeStatus: usersMe.status,
|
||||
usersProfilePresent: Boolean(
|
||||
usersData?.profile && typeof usersData.profile === 'object',
|
||||
),
|
||||
latestPreStatus: latestPre.status,
|
||||
latestPrePhase: latestData?.phase ?? null,
|
||||
journeyStatus: journey.status,
|
||||
journeyStep: journeyData?.currentStep ?? null,
|
||||
journeyPreSet: Boolean(journeyData?.preTestCompletedAt),
|
||||
progressStatus: sessionProgress.status,
|
||||
currentSessionOrder: progressData?.currentSessionOrder ?? null,
|
||||
chatHistoryStatus: chatMessages.status,
|
||||
chatHistoryCount: Array.isArray((chatData as { messages?: unknown[] } | null)?.messages)
|
||||
? (chatData as { messages: unknown[] }).messages.length
|
||||
: 0,
|
||||
chatOwnedByUser: chatOwned,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await dest.$disconnect();
|
||||
await source.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user