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:
ar.badiei
2026-09-03 21:01:52 +03:30
co-authored by Cursor
parent aa9236fb92
commit 370ac15471
193 changed files with 25997 additions and 1991 deletions
@@ -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);
});
+172
View File
@@ -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);
});