v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)

This commit is contained in:
Amruth Pillai
2026-07-04 14:57:25 +02:00
committed by GitHub
parent 09bc6ec521
commit 57e9c8c487
181 changed files with 46794 additions and 11348 deletions
+19
View File
@@ -105,4 +105,23 @@ export const resumeDto = {
input: resumeSchema.pick({ id: true }),
output: z.void(),
},
listVersions: {
input: z.object({ resumeId: z.string().describe("The ID of the resume whose version history to list.") }),
output: z.array(
z.object({
id: z.string().describe("The ID of the version snapshot."),
label: z.string().describe("A short description of what triggered the snapshot."),
createdAt: z.date().describe("The date and time the snapshot was taken."),
}),
),
},
restoreVersion: {
input: z.object({
resumeId: z.string().describe("The ID of the resume to restore."),
versionId: z.string().describe("The ID of the version snapshot to restore."),
}),
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
},
};
+99 -20
View File
@@ -818,31 +818,33 @@ function createAgent(input: {
});
}
const threadSummarySelection = {
id: schema.agentThread.id,
userId: schema.agentThread.userId,
aiProviderId: schema.agentThread.aiProviderId,
sourceResumeId: schema.agentThread.sourceResumeId,
workingResumeId: schema.agentThread.workingResumeId,
title: schema.agentThread.title,
status: schema.agentThread.status,
activeRunId: schema.agentThread.activeRunId,
activeStreamId: schema.agentThread.activeStreamId,
activeRunStartedAt: schema.agentThread.activeRunStartedAt,
lastMessageAt: schema.agentThread.lastMessageAt,
archivedAt: schema.agentThread.archivedAt,
deletedAt: schema.agentThread.deletedAt,
createdAt: schema.agentThread.createdAt,
updatedAt: schema.agentThread.updatedAt,
resumeName: schema.resume.name,
providerLabel: schema.aiProvider.label,
};
export const agentService = {
threads: {
list: async (input: { userId: string }) => {
assertAgentEnvironment();
const rows = await db
.select({
id: schema.agentThread.id,
userId: schema.agentThread.userId,
aiProviderId: schema.agentThread.aiProviderId,
sourceResumeId: schema.agentThread.sourceResumeId,
workingResumeId: schema.agentThread.workingResumeId,
title: schema.agentThread.title,
status: schema.agentThread.status,
activeRunId: schema.agentThread.activeRunId,
activeStreamId: schema.agentThread.activeStreamId,
activeRunStartedAt: schema.agentThread.activeRunStartedAt,
lastMessageAt: schema.agentThread.lastMessageAt,
archivedAt: schema.agentThread.archivedAt,
deletedAt: schema.agentThread.deletedAt,
createdAt: schema.agentThread.createdAt,
updatedAt: schema.agentThread.updatedAt,
resumeName: schema.resume.name,
providerLabel: schema.aiProvider.label,
})
.select(threadSummarySelection)
.from(schema.agentThread)
.leftJoin(schema.resume, eq(schema.agentThread.workingResumeId, schema.resume.id))
.leftJoin(schema.aiProvider, eq(schema.agentThread.aiProviderId, schema.aiProvider.id))
@@ -882,6 +884,78 @@ export const agentService = {
});
},
// In-resume assistant threads edit the open resume directly (working === source === resumeId), so the
// builder's resume-update subscription applies the agent's patches live. Reuses the latest active thread
// for that resume rather than accumulating a new thread on every panel open.
getOrCreateForResume: async (input: { userId: string; resumeId: string; aiProviderId?: string }) => {
assertAgentEnvironment();
const [existing] = await db
.select(threadSummarySelection)
.from(schema.agentThread)
.leftJoin(schema.resume, eq(schema.agentThread.workingResumeId, schema.resume.id))
.leftJoin(schema.aiProvider, eq(schema.agentThread.aiProviderId, schema.aiProvider.id))
.where(
and(
eq(schema.agentThread.userId, input.userId),
eq(schema.agentThread.workingResumeId, input.resumeId),
eq(schema.agentThread.sourceResumeId, input.resumeId),
eq(schema.agentThread.status, "active"),
isNull(schema.agentThread.deletedAt),
),
)
.orderBy(desc(schema.agentThread.lastMessageAt))
.limit(1);
if (existing) return toThreadSummary(existing);
const selectedProvider = input.aiProviderId
? await aiProvidersService.getRunnableById({ id: input.aiProviderId, userId: input.userId })
: await aiProvidersService.getDefaultRunnable({ userId: input.userId });
if (!selectedProvider) throw new ORPCError("BAD_REQUEST", { message: "No tested AI provider is available." });
// Confirms the caller owns the resume (throws otherwise) and provides its name for the summary.
const resume = await resumeService.getById({ id: input.resumeId, userId: input.userId });
const [thread] = await db
.insert(schema.agentThread)
.values({
userId: input.userId,
aiProviderId: selectedProvider.id,
sourceResumeId: input.resumeId,
workingResumeId: input.resumeId,
title: "Resume assistant",
})
.onConflictDoNothing()
.returning();
// A concurrent call won the unique partial index race; return its thread instead.
if (!thread) {
const [raced] = await db
.select(threadSummarySelection)
.from(schema.agentThread)
.leftJoin(schema.resume, eq(schema.agentThread.workingResumeId, schema.resume.id))
.leftJoin(schema.aiProvider, eq(schema.agentThread.aiProviderId, schema.aiProvider.id))
.where(
and(
eq(schema.agentThread.userId, input.userId),
eq(schema.agentThread.workingResumeId, input.resumeId),
eq(schema.agentThread.sourceResumeId, input.resumeId),
eq(schema.agentThread.status, "active"),
isNull(schema.agentThread.deletedAt),
),
)
.orderBy(desc(schema.agentThread.lastMessageAt))
.limit(1);
if (!raced) throw new Error("AGENT_THREAD_CREATE_FAILED");
return toThreadSummary(raced);
}
return toThreadSummary({ ...thread, resumeName: resume.name, providerLabel: selectedProvider.label });
},
get: async (input: { id: string; userId: string }) => {
assertAgentEnvironment();
@@ -909,7 +983,12 @@ export const agentService = {
actions: actions.map(toAction),
attachments: attachments.map(toAttachment),
resume,
isReadOnly: thread.status === "archived" || !thread.workingResumeId || !thread.aiProviderId || !resume,
isReadOnly:
thread.status === "archived" ||
!thread.workingResumeId ||
!thread.aiProviderId ||
!resume ||
!!resume.isLocked,
};
},
@@ -44,6 +44,28 @@ export const threadsRouter = {
}
}),
getOrCreateForResume: protectedProcedure
.route({
method: "POST",
path: "/agent/threads/for-resume",
tags: ["Agent"],
operationId: "getOrCreateAgentThreadForResume",
summary: "Get or create an in-resume agent thread",
})
.input(z.object({ resumeId: z.string(), aiProviderId: z.string().optional() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.getOrCreateForResume({
userId: context.user.id,
resumeId: input.resumeId,
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
get: protectedProcedure
.route({
method: "GET",
+15
View File
@@ -20,6 +20,21 @@ export const authRouter = {
}),
},
exportData: protectedProcedure
.route({
method: "GET",
path: "/auth/account/export",
tags: ["Authentication"],
operationId: "exportAccountData",
summary: "Export user account data",
description:
"Returns a JSON-serializable export of the authenticated user's data, including their public profile fields and all of their resumes. Secrets such as password hashes, tokens, and API keys are never included. Requires authentication.",
successDescription: "The user's exported account data.",
})
.handler(async ({ context }) => {
return await authService.exportData({ userId: context.user.id });
}),
deleteAccount: protectedProcedure
.route({
method: "DELETE",
+41 -4
View File
@@ -24,17 +24,54 @@ const providers = {
export const authService = {
providers,
deleteAccount: async (input: { userId: string }): Promise<void> => {
if (!input.userId || input.userId.length === 0) return;
// GDPR-style export of everything the user owns. Selects explicit columns so
// secrets (password hashes, tokens, api keys) never leak into the export.
exportData: async (input: { userId: string }) => {
const [userRecord] = await db
.select({
id: schema.user.id,
name: schema.user.name,
email: schema.user.email,
username: schema.user.username,
displayUsername: schema.user.displayUsername,
image: schema.user.image,
emailVerified: schema.user.emailVerified,
createdAt: schema.user.createdAt,
updatedAt: schema.user.updatedAt,
})
.from(schema.user)
.where(eq(schema.user.id, input.userId));
if (!userRecord) throw new ORPCError("NOT_FOUND");
const resumes = await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
createdAt: schema.resume.createdAt,
updatedAt: schema.resume.updatedAt,
})
.from(schema.resume)
.where(eq(schema.resume.userId, input.userId));
return { exportedAt: new Date().toISOString(), user: userRecord, resumes };
},
deleteAccount: async (input: { userId: string }): Promise<void> => {
const storageService = getStorageService();
// Delete all user files in one call (pictures, screenshots, pdfs)
// The storage service delete method supports recursive deletion via prefix
try {
await storageService.delete(`uploads/${input.userId}`);
} catch {
// Ignore error and proceed with deleting user
} catch (err) {
// Log orphaned-file failures (GDPR erasure signal) but proceed with deleting the user.
console.error("Failed to delete storage for user %s:", input.userId, err);
}
try {
@@ -6,8 +6,12 @@ export type FeatureFlags = {
disableSignups: boolean;
disableEmailAuth: boolean;
showSponsors: boolean;
smtpEnabled: boolean;
};
// Mirrors isSmtpEnabled() in packages/email/src/transport.ts (kept local to avoid an api -> email dependency).
const isSmtpEnabled = () => Boolean(env.SMTP_HOST && env.SMTP_USER && env.SMTP_PASS && env.SMTP_FROM);
export const flagsRouter = {
get: publicProcedure
.route({
@@ -25,6 +29,7 @@ export const flagsRouter = {
disableSignups: z.boolean().describe("Whether new user signups are disabled on this instance."),
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
showSponsors: z.boolean().describe("Whether sponsor placements are shown on this instance."),
smtpEnabled: z.boolean().describe("Whether outbound email (SMTP) is configured on this instance."),
}),
)
.handler(
@@ -32,6 +37,7 @@ export const flagsRouter = {
disableSignups: env.FLAG_DISABLE_SIGNUPS,
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
showSponsors: env.FLAG_SHOW_SPONSORS,
smtpEnabled: isSmtpEnabled(),
}),
),
};
+13 -3
View File
@@ -1,4 +1,4 @@
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { createSampleResumeData } from "@reactive-resume/schema/resume/sample";
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
@@ -71,7 +71,7 @@ export const crudRouter = {
tags: input.tags,
locale: context.locale,
userId: context.user.id,
...(input.withSampleData ? { data: sampleResumeData } : {}),
...(input.withSampleData ? { data: createSampleResumeData(input.name) } : {}),
});
}),
@@ -99,7 +99,7 @@ export const crudRouter = {
const name = generateRandomName();
const slug = slugify(name);
return resumeService.create({
const id = await resumeService.create({
name,
slug,
tags: [],
@@ -107,6 +107,16 @@ export const crudRouter = {
locale: context.locale,
userId: context.user.id,
});
// Milestone checkpoint for the imported document (best-effort).
await resumeService.versions.snapshot({
resumeId: id,
userId: context.user.id,
data: input.data,
label: "Imported",
});
return id;
}),
update: protectedProcedure
@@ -4,6 +4,7 @@ import { updatesRouter } from "./event-router";
import { sharingRouter } from "./sharing";
import { resumeStatisticsRouter } from "./statistics";
import { tagsRouter } from "./tags";
import { versionsRouter } from "./versions";
export const resumeRouter = {
tags: tagsRouter,
@@ -24,4 +25,6 @@ export const resumeRouter = {
removePassword: sharingRouter.removePassword,
duplicate: crudRouter.duplicate,
delete: crudRouter.delete,
listVersions: versionsRouter.listVersions,
restoreVersion: versionsRouter.restoreVersion,
};
+217 -19
View File
@@ -5,7 +5,7 @@ import type { Locale } from "@reactive-resume/utils/locale";
import type { ResumeUpdatedEvent } from "./events";
import { ORPCError } from "@orpc/client";
import { compare, hash } from "bcrypt";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { and, arrayContains, asc, desc, eq, gte, isNotNull, notInArray, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { db } from "@reactive-resume/db/client";
@@ -28,9 +28,63 @@ function resumeVersionConflict(updatedAt: Date) {
});
}
// Version history: keep a bounded, rolling window of snapshots per resume.
const MAX_VERSIONS_PER_RESUME = 30;
// Manual-save milestones are debounced server-side: an autosave only checkpoints if the newest
// snapshot is older than this. Explicit milestones (import, AI edit, restore) always checkpoint.
const SNAPSHOT_THROTTLE_MS = 2 * 60 * 1000;
async function writeResumeVersion(
client: DbOrTx,
input: { resumeId: string; userId: string; data: ResumeData; label: string },
) {
await client.insert(schema.resumeVersion).values({
resumeId: input.resumeId,
userId: input.userId,
data: input.data,
label: input.label,
});
// Prune everything beyond the newest N snapshots for this resume.
const keep = client
.select({ id: schema.resumeVersion.id })
.from(schema.resumeVersion)
.where(eq(schema.resumeVersion.resumeId, input.resumeId))
.orderBy(desc(schema.resumeVersion.createdAt))
.limit(MAX_VERSIONS_PER_RESUME);
await client
.delete(schema.resumeVersion)
.where(and(eq(schema.resumeVersion.resumeId, input.resumeId), notInArray(schema.resumeVersion.id, keep)));
}
// Best-effort, throttled snapshot on the autosave/manual-save path. Never blocks or fails the save.
async function maybeSnapshotOnSave(input: { resumeId: string; userId: string; data: ResumeData; label: string }) {
try {
const [latest] = await db
.select({ createdAt: schema.resumeVersion.createdAt })
.from(schema.resumeVersion)
.where(eq(schema.resumeVersion.resumeId, input.resumeId))
.orderBy(desc(schema.resumeVersion.createdAt))
.limit(1);
if (latest && Date.now() - latest.createdAt.getTime() < SNAPSHOT_THROTTLE_MS) return;
await writeResumeVersion(db, input);
} catch (error) {
console.warn("Failed to snapshot resume version:", error);
}
}
async function applyResumePatchTx(
client: DbOrTx,
input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date },
input: {
id: string;
userId: string;
operations: JsonPatchOperation[];
expectedUpdatedAt?: Date;
versionLabel?: string;
},
) {
const [existing] = await client
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked, updatedAt: schema.resume.updatedAt })
@@ -91,6 +145,15 @@ async function applyResumePatchTx(
throw new ORPCError("NOT_FOUND");
}
// Checkpoint every patch (AI/API edit) atomically within the same transaction as the edit.
// ponytail: a multi-patch agent turn writes one row per patch; the prune cap (30) bounds it.
await writeResumeVersion(client, {
resumeId: resume.id,
userId: input.userId,
data: resume.data,
label: input.versionLabel ?? "AI edit",
});
return resume;
}
@@ -138,25 +201,74 @@ const statistics = {
const downloads = input.downloads ? 1 : 0;
const lastViewedAt = input.views ? sql`now()` : undefined;
const lastDownloadedAt = input.downloads ? sql`now()` : undefined;
const today = new Date().toISOString().slice(0, 10);
await db
.insert(schema.resumeStatistics)
.values({
resumeId: input.id,
views,
downloads,
lastViewedAt,
lastDownloadedAt,
})
.onConflictDoUpdate({
target: [schema.resumeStatistics.resumeId],
set: {
views: sql`${schema.resumeStatistics.views} + ${views}`,
downloads: sql`${schema.resumeStatistics.downloads} + ${downloads}`,
await db.transaction(async (tx) => {
await tx
.insert(schema.resumeStatistics)
.values({
resumeId: input.id,
views,
downloads,
lastViewedAt,
lastDownloadedAt,
},
});
})
.onConflictDoUpdate({
target: [schema.resumeStatistics.resumeId],
set: {
views: sql`${schema.resumeStatistics.views} + ${views}`,
downloads: sql`${schema.resumeStatistics.downloads} + ${downloads}`,
lastViewedAt,
lastDownloadedAt,
},
});
await tx
.insert(schema.resumeStatisticsDaily)
.values({ resumeId: input.id, date: today, views, downloads })
.onConflictDoUpdate({
target: [schema.resumeStatisticsDaily.resumeId, schema.resumeStatisticsDaily.date],
set: {
views: sql`${schema.resumeStatisticsDaily.views} + ${views}`,
downloads: sql`${schema.resumeStatisticsDaily.downloads} + ${downloads}`,
},
});
});
},
// Returns the last `days` (default 30) of daily view/download counts, zero-filled so the series is continuous.
getDailySeries: async (input: { id: string; userId: string; days?: number }) => {
const days = input.days ?? 30;
const [resume] = await db
.select({ id: schema.resume.id })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
const now = new Date();
const utcDay = (offset: number) =>
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - offset)).toISOString().slice(0, 10);
const start = utcDay(days - 1);
const dates = Array.from({ length: days }, (_, i) => utcDay(days - 1 - i));
const rows = await db
.select({
date: schema.resumeStatisticsDaily.date,
views: schema.resumeStatisticsDaily.views,
downloads: schema.resumeStatisticsDaily.downloads,
})
.from(schema.resumeStatisticsDaily)
.where(and(eq(schema.resumeStatisticsDaily.resumeId, input.id), gte(schema.resumeStatisticsDaily.date, start)));
const byDate = new Map(rows.map((row) => [row.date, row]));
return dates.map((date) => ({
date,
views: byDate.get(date)?.views ?? 0,
downloads: byDate.get(date)?.downloads ?? 0,
}));
},
};
@@ -235,6 +347,80 @@ export const resumeService = {
statistics,
analysis,
versions: {
list: async (input: { resumeId: string; userId: string }) => {
const [owner] = await db
.select({ id: schema.resume.id })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.resumeId), eq(schema.resume.userId, input.userId)));
if (!owner) throw new ORPCError("NOT_FOUND");
return db
.select({
id: schema.resumeVersion.id,
label: schema.resumeVersion.label,
createdAt: schema.resumeVersion.createdAt,
})
.from(schema.resumeVersion)
.where(eq(schema.resumeVersion.resumeId, input.resumeId))
.orderBy(desc(schema.resumeVersion.createdAt))
.limit(MAX_VERSIONS_PER_RESUME);
},
// Best-effort checkpoint used by non-transactional milestones (e.g. import).
snapshot: async (input: { resumeId: string; userId: string; data: ResumeData; label: string }) => {
try {
await writeResumeVersion(db, input);
} catch (error) {
console.warn("Failed to snapshot resume version:", error);
}
},
// Non-destructive restore: writes the snapshot's data back through the normal update path, so
// prior versions remain and the restore is itself just another (snapshot-able, undoable) change.
restore: async (input: { resumeId: string; versionId: string; userId: string }) => {
const [version] = await db
.select({ data: schema.resumeVersion.data })
.from(schema.resumeVersion)
.innerJoin(schema.resume, eq(schema.resumeVersion.resumeId, schema.resume.id))
.where(
and(
eq(schema.resumeVersion.id, input.versionId),
eq(schema.resumeVersion.resumeId, input.resumeId),
eq(schema.resume.userId, input.userId),
),
);
if (!version) throw new ORPCError("NOT_FOUND");
// Capture the pre-restore state first so the restore itself is undoable.
const current = await resumeService.getById({ id: input.resumeId, userId: input.userId });
await resumeService.versions.snapshot({
resumeId: input.resumeId,
userId: input.userId,
data: current.data,
label: "Before restore",
});
const updated = await resumeService.update({
id: input.resumeId,
userId: input.userId,
data: version.data,
skipAutoSnapshot: true,
});
await resumeService.versions.snapshot({
resumeId: input.resumeId,
userId: input.userId,
data: version.data,
label: "Restored version",
});
return updated;
},
},
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
return await db
.select({
@@ -374,6 +560,7 @@ export const resumeService = {
tags?: string[];
data?: ResumeData;
isPublic?: boolean;
skipAutoSnapshot?: boolean;
}) => {
const [resume] = await db
.select({ isLocked: schema.resume.isLocked })
@@ -415,6 +602,17 @@ export const resumeService = {
if (!resume) throw new ORPCError("NOT_FOUND");
// Debounced manual-save milestone: only snapshots data edits, and only when the previous
// snapshot is old enough (see SNAPSHOT_THROTTLE_MS). Covers template switches and typing.
if (input.data !== undefined && !input.skipAutoSnapshot) {
await maybeSnapshotOnSave({
resumeId: resume.id,
userId: input.userId,
data: resume.data,
label: "Manual save",
});
}
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
@@ -437,7 +635,7 @@ export const resumeService = {
},
patch: async (input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date }) => {
const resume = await applyResumePatchTx(db, input);
const resume = await db.transaction((tx) => applyResumePatchTx(tx, input));
await notifyResumeUpdated({
type: "resume.updated",
@@ -27,4 +27,34 @@ export const resumeStatisticsRouter = {
.handler(async ({ context, input }) => {
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
getDailyById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/statistics/daily",
tags: ["Resume Statistics"],
operationId: "getResumeDailyStatistics",
summary: "Get resume daily statistics",
description:
"Returns a continuous, zero-filled per-day series of view and download counts for the specified resume over the last `days` days (UTC). Requires authentication and resume ownership.",
successDescription: "The resume's daily view and download statistics.",
})
.input(
z.object({
id: z.string().describe("The unique identifier of the resume."),
days: z.number().int().min(1).max(365).default(30).describe("Number of trailing days to include."),
}),
)
.output(
z.array(
z.object({
date: z.string().describe("The UTC day in YYYY-MM-DD format."),
views: z.number().describe("Number of views recorded on this day."),
downloads: z.number().describe("Number of downloads recorded on this day."),
}),
),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getDailySeries({ id: input.id, userId: context.user.id, days: input.days });
}),
};
@@ -0,0 +1,45 @@
import { protectedProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export const versionsRouter = {
listVersions: protectedProcedure
.route({
method: "GET",
path: "/resumes/{resumeId}/versions",
tags: ["Resumes"],
operationId: "listResumeVersions",
summary: "List resume version history",
description:
"Returns the recent version-history snapshots for a resume (id, label, and timestamp only). Snapshots are taken at milestones such as imports, AI edits, and periodic manual saves. Only the resume owner can list versions. Requires authentication.",
successDescription: "A list of recent version snapshots, newest first.",
})
.input(resumeDto.listVersions.input)
.output(resumeDto.listVersions.output)
.handler(async ({ context, input }) => {
return resumeService.versions.list({ resumeId: input.resumeId, userId: context.user.id });
}),
restoreVersion: protectedProcedure
.route({
method: "POST",
path: "/resumes/{resumeId}/versions/{versionId}/restore",
tags: ["Resumes"],
operationId: "restoreResumeVersion",
summary: "Restore a resume version",
description:
"Non-destructively restores a resume to a previous version snapshot by writing that snapshot's data back through the normal update path. Prior versions are preserved and the restore itself becomes a new snapshot. Only the resume owner can restore versions. Requires authentication.",
successDescription: "The restored resume with its full data.",
})
.input(resumeDto.restoreVersion.input)
.use(resumeMutationRateLimit)
.output(resumeDto.restoreVersion.output)
.handler(async ({ context, input }) => {
return resumeService.versions.restore({
resumeId: input.resumeId,
versionId: input.versionId,
userId: context.user.id,
});
}),
};