mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user