Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
8.9 KiB
TypeScript
267 lines
8.9 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|