mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
refactor(api): share one version history between resumes and letters
This commit is contained in:
@@ -1,25 +1,8 @@
|
||||
import type { DbOrTx } from "@reactive-resume/db/client";
|
||||
import type { CoverLetterVersionData, CoverLetterVersionKind } from "@reactive-resume/db/schema";
|
||||
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
|
||||
// The same retention as resumes (see resume/version-history.ts): sessions refresh their autosave at most every two
|
||||
// minutes; autosaves and restore markers last 90 days; at most 500 autosaves per letter.
|
||||
const SESSION_REFRESH_MS = 2 * 60 * 1000;
|
||||
const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
const EXPIRING_KINDS: CoverLetterVersionKind[] = ["auto", "restored"];
|
||||
const MAX_AUTOSAVES = 500;
|
||||
const LIST_LIMIT = 100;
|
||||
|
||||
const summary = {
|
||||
id: schema.coverLetterVersion.id,
|
||||
kind: schema.coverLetterVersion.kind,
|
||||
name: schema.coverLetterVersion.name,
|
||||
createdAt: schema.coverLetterVersion.createdAt,
|
||||
};
|
||||
import { createVersionHistory } from "../documents/version-history";
|
||||
|
||||
/** The parts of a letter a version keeps. */
|
||||
const letterVersionData = (letter: CoverLetter): CoverLetterVersionData => ({
|
||||
@@ -33,139 +16,63 @@ const letterVersionData = (letter: CoverLetter): CoverLetterVersionData => ({
|
||||
letterDate: letter.letterDate,
|
||||
});
|
||||
|
||||
type VersionInput = {
|
||||
letter: CoverLetter;
|
||||
userId: string;
|
||||
kind: CoverLetterVersionKind;
|
||||
name?: string | null;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export async function writeLetterVersion(client: DbOrTx, input: VersionInput) {
|
||||
const [version] = await client
|
||||
.insert(schema.coverLetterVersion)
|
||||
.values({
|
||||
coverLetterId: input.letter.id,
|
||||
userId: input.userId,
|
||||
data: letterVersionData(input.letter),
|
||||
kind: input.kind,
|
||||
name: input.name ?? null,
|
||||
sessionId: input.sessionId ?? null,
|
||||
})
|
||||
.returning(summary);
|
||||
if (!version) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to save the version." });
|
||||
|
||||
await pruneLetterVersions(client, input.letter.id);
|
||||
return version;
|
||||
}
|
||||
|
||||
async function pruneLetterVersions(client: DbOrTx, coverLetterId: string) {
|
||||
const table = schema.coverLetterVersion;
|
||||
await client
|
||||
.delete(table)
|
||||
.where(
|
||||
and(
|
||||
eq(table.coverLetterId, coverLetterId),
|
||||
inArray(table.kind, EXPIRING_KINDS),
|
||||
lt(table.createdAt, new Date(Date.now() - RETENTION_MS)),
|
||||
),
|
||||
);
|
||||
|
||||
const newestAutosaves = client
|
||||
.select({ id: table.id })
|
||||
.from(table)
|
||||
.where(and(eq(table.coverLetterId, coverLetterId), eq(table.kind, "auto")))
|
||||
.orderBy(desc(table.createdAt))
|
||||
.limit(MAX_AUTOSAVES);
|
||||
|
||||
await client
|
||||
.delete(table)
|
||||
.where(and(eq(table.coverLetterId, coverLetterId), eq(table.kind, "auto"), notInArray(table.id, newestAutosaves)));
|
||||
}
|
||||
|
||||
/** The autosave path: one version per editing session, refreshed at most every two minutes. Never fails the save. */
|
||||
export async function saveLetterSessionVersion(input: { letter: CoverLetter; userId: string; sessionId?: string }) {
|
||||
const table = schema.coverLetterVersion;
|
||||
try {
|
||||
const [latest] = await db
|
||||
.select({ id: table.id, createdAt: table.createdAt })
|
||||
.from(table)
|
||||
.where(
|
||||
and(
|
||||
eq(table.coverLetterId, input.letter.id),
|
||||
...(input.sessionId ? [eq(table.kind, "auto"), eq(table.sessionId, input.sessionId)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(table.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (latest && Date.now() - latest.createdAt.getTime() < SESSION_REFRESH_MS) return;
|
||||
|
||||
if (latest && input.sessionId) {
|
||||
await db
|
||||
.update(table)
|
||||
.set({ data: letterVersionData(input.letter), createdAt: new Date() })
|
||||
.where(eq(table.id, latest.id));
|
||||
return;
|
||||
}
|
||||
|
||||
await writeLetterVersion(db, { ...input, kind: "auto" });
|
||||
} catch (error) {
|
||||
console.warn("Failed to save the letter's session version:", error);
|
||||
}
|
||||
}
|
||||
const t = schema.coverLetterVersion;
|
||||
const history = createVersionHistory<CoverLetterVersionKind, CoverLetter>({
|
||||
versions: { ...t, table: t, document: t.coverLetterId, documentKey: "coverLetterId" },
|
||||
owner: { table: schema.coverLetter, id: schema.coverLetter.id, userId: schema.coverLetter.userId },
|
||||
expiringKinds: ["auto", "restored"],
|
||||
toStored: letterVersionData,
|
||||
label: "letter",
|
||||
});
|
||||
|
||||
type Owned = { coverLetterId: string; userId: string };
|
||||
|
||||
const ownedVersion = (input: Owned & { versionId: string }) =>
|
||||
and(
|
||||
eq(schema.coverLetterVersion.id, input.versionId),
|
||||
eq(schema.coverLetterVersion.coverLetterId, input.coverLetterId),
|
||||
eq(schema.coverLetterVersion.userId, input.userId),
|
||||
);
|
||||
export const writeLetterVersion = (
|
||||
client: DbOrTx,
|
||||
input: {
|
||||
letter: CoverLetter;
|
||||
userId: string;
|
||||
kind: CoverLetterVersionKind;
|
||||
name?: string | null;
|
||||
sessionId?: string;
|
||||
},
|
||||
) =>
|
||||
history.write(client, {
|
||||
documentId: input.letter.id,
|
||||
userId: input.userId,
|
||||
data: input.letter,
|
||||
kind: input.kind,
|
||||
name: input.name,
|
||||
sessionId: input.sessionId,
|
||||
});
|
||||
|
||||
async function assertOwnsLetter(input: Owned) {
|
||||
const [owner] = await db
|
||||
.select({ id: schema.coverLetter.id })
|
||||
.from(schema.coverLetter)
|
||||
.where(and(eq(schema.coverLetter.id, input.coverLetterId), eq(schema.coverLetter.userId, input.userId)));
|
||||
if (!owner) throw new ORPCError("NOT_FOUND");
|
||||
}
|
||||
export const saveLetterSessionVersion = (input: { letter: CoverLetter; userId: string; sessionId?: string }) =>
|
||||
history.saveSession({
|
||||
documentId: input.letter.id,
|
||||
userId: input.userId,
|
||||
data: input.letter,
|
||||
sessionId: input.sessionId,
|
||||
});
|
||||
|
||||
export async function listLetterVersions(input: Owned) {
|
||||
await assertOwnsLetter(input);
|
||||
return db
|
||||
.select(summary)
|
||||
.from(schema.coverLetterVersion)
|
||||
.where(eq(schema.coverLetterVersion.coverLetterId, input.coverLetterId))
|
||||
.orderBy(desc(schema.coverLetterVersion.createdAt))
|
||||
.limit(LIST_LIMIT);
|
||||
}
|
||||
export const listLetterVersions = (input: Owned) =>
|
||||
history.list({ documentId: input.coverLetterId, userId: input.userId });
|
||||
|
||||
export async function getLetterVersion(input: Owned & { versionId: string }) {
|
||||
const [version] = await db
|
||||
.select({ ...summary, data: schema.coverLetterVersion.data })
|
||||
.from(schema.coverLetterVersion)
|
||||
.where(ownedVersion(input));
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
return version;
|
||||
}
|
||||
export const getLetterVersion = async (input: Owned & { versionId: string }) => {
|
||||
const version = await history.get({
|
||||
documentId: input.coverLetterId,
|
||||
userId: input.userId,
|
||||
versionId: input.versionId,
|
||||
});
|
||||
return { ...version, data: version.data as CoverLetterVersionData };
|
||||
};
|
||||
|
||||
/** Named versions are the user's own; only they can be renamed or deleted. */
|
||||
export async function renameLetterVersion(input: Owned & { versionId: string; name: string }) {
|
||||
const [version] = await db
|
||||
.update(schema.coverLetterVersion)
|
||||
.set({ name: input.name })
|
||||
.where(and(ownedVersion(input), eq(schema.coverLetterVersion.kind, "named")))
|
||||
.returning(summary);
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
return version;
|
||||
}
|
||||
export const renameLetterVersion = (input: Owned & { versionId: string; name: string }) =>
|
||||
history.rename({
|
||||
documentId: input.coverLetterId,
|
||||
userId: input.userId,
|
||||
versionId: input.versionId,
|
||||
name: input.name,
|
||||
});
|
||||
|
||||
export async function deleteLetterVersion(input: Owned & { versionId: string }) {
|
||||
const [version] = await db
|
||||
.delete(schema.coverLetterVersion)
|
||||
.where(and(ownedVersion(input), eq(schema.coverLetterVersion.kind, "named")))
|
||||
.returning({ id: schema.coverLetterVersion.id });
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
}
|
||||
export const deleteLetterVersion = (input: Owned & { versionId: string }) =>
|
||||
history.remove({ documentId: input.coverLetterId, userId: input.userId, versionId: input.versionId });
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { DbOrTx } from "@reactive-resume/db/client";
|
||||
import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
|
||||
// An editing session's autosave is refreshed at most this often.
|
||||
const SESSION_REFRESH_MS = 2 * 60 * 1000;
|
||||
// Expiring kinds (autosaves, restore markers, AI edits) are kept this long; the rest stay until deleted.
|
||||
const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
// A safety cap on autosaves per document, so storage stays bounded however often it's edited.
|
||||
const MAX_AUTOSAVES = 500;
|
||||
// History shows at most this many versions, newest first.
|
||||
const LIST_LIMIT = 100;
|
||||
|
||||
export type VersionSummary<TKind extends string> = { id: string; kind: TKind; name: string | null; createdAt: Date };
|
||||
|
||||
type VersionTable = {
|
||||
table: PgTable;
|
||||
id: PgColumn;
|
||||
/** The document the version belongs to (`resumeId`, `coverLetterId`). */
|
||||
document: PgColumn;
|
||||
documentKey: string;
|
||||
userId: PgColumn;
|
||||
data: PgColumn;
|
||||
kind: PgColumn;
|
||||
name: PgColumn;
|
||||
sessionId: PgColumn;
|
||||
createdAt: PgColumn;
|
||||
};
|
||||
|
||||
type Owned = { documentId: string; userId: string };
|
||||
|
||||
/**
|
||||
* History for one kind of document (resumes, letters): versions are written with a kind, autosaves collapse into one
|
||||
* per editing session, expiring kinds age out after 90 days, and only named versions can be renamed or deleted.
|
||||
* Retention runs whenever a version is written, so it needs no scheduler.
|
||||
*/
|
||||
export function createVersionHistory<TKind extends string, TData>(config: {
|
||||
versions: VersionTable;
|
||||
/** The document table, for the ownership check before listing. */
|
||||
owner: { table: PgTable; id: PgColumn; userId: PgColumn };
|
||||
expiringKinds: readonly TKind[];
|
||||
/** Every write stores data through this (validation, normalisation). */
|
||||
toStored: (data: TData) => unknown;
|
||||
label: string;
|
||||
}) {
|
||||
const v = config.versions;
|
||||
const summary = { id: v.id, kind: v.kind, name: v.name, createdAt: v.createdAt };
|
||||
const ownedVersion = (input: Owned & { versionId: string }) =>
|
||||
and(eq(v.id, input.versionId), eq(v.document, input.documentId), eq(v.userId, input.userId));
|
||||
|
||||
async function prune(client: DbOrTx, documentId: string) {
|
||||
await client
|
||||
.delete(v.table)
|
||||
.where(
|
||||
and(
|
||||
eq(v.document, documentId),
|
||||
inArray(v.kind, [...config.expiringKinds]),
|
||||
lt(v.createdAt, new Date(Date.now() - RETENTION_MS)),
|
||||
),
|
||||
);
|
||||
|
||||
const newestAutosaves = client
|
||||
.select({ id: v.id })
|
||||
.from(v.table)
|
||||
.where(and(eq(v.document, documentId), eq(v.kind, "auto")))
|
||||
.orderBy(desc(v.createdAt))
|
||||
.limit(MAX_AUTOSAVES);
|
||||
|
||||
await client
|
||||
.delete(v.table)
|
||||
.where(and(eq(v.document, documentId), eq(v.kind, "auto"), notInArray(v.id, newestAutosaves)));
|
||||
}
|
||||
|
||||
async function write(
|
||||
client: DbOrTx,
|
||||
input: Owned & { data: TData; kind: TKind; name?: string | null | undefined; sessionId?: string | undefined },
|
||||
) {
|
||||
const [version] = await client
|
||||
.insert(v.table)
|
||||
.values({
|
||||
[v.documentKey]: input.documentId,
|
||||
userId: input.userId,
|
||||
data: config.toStored(input.data),
|
||||
kind: input.kind,
|
||||
name: input.name ?? null,
|
||||
sessionId: input.sessionId ?? null,
|
||||
} as never)
|
||||
.returning(summary);
|
||||
if (!version) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to save the version." });
|
||||
|
||||
await prune(client, input.documentId);
|
||||
return version as VersionSummary<TKind>;
|
||||
}
|
||||
|
||||
return {
|
||||
write,
|
||||
|
||||
/**
|
||||
* The autosave path. Each editing session keeps one version holding its latest state, refreshed at most every
|
||||
* two minutes. Callers that send no session (API clients) get a new autosave once the newest version is two
|
||||
* minutes old. Best effort: it never fails or delays the save beyond its own queries.
|
||||
*/
|
||||
async saveSession(input: Owned & { data: TData; sessionId?: string | undefined }) {
|
||||
try {
|
||||
const [latest] = await db
|
||||
.select({ id: v.id, createdAt: v.createdAt })
|
||||
.from(v.table)
|
||||
.where(
|
||||
and(
|
||||
eq(v.document, input.documentId),
|
||||
...(input.sessionId ? [eq(v.kind, "auto"), eq(v.sessionId, input.sessionId)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(v.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (latest && Date.now() - (latest.createdAt as Date).getTime() < SESSION_REFRESH_MS) return;
|
||||
|
||||
if (latest && input.sessionId) {
|
||||
await db
|
||||
.update(v.table)
|
||||
.set({ data: config.toStored(input.data), createdAt: new Date() } as never)
|
||||
.where(eq(v.id, latest.id));
|
||||
return;
|
||||
}
|
||||
|
||||
await write(db, { ...input, kind: "auto" as TKind });
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save the ${config.label}'s session version:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
async list(input: Owned) {
|
||||
const [owner] = await db
|
||||
.select({ id: config.owner.id })
|
||||
.from(config.owner.table)
|
||||
.where(and(eq(config.owner.id, input.documentId), eq(config.owner.userId, input.userId)));
|
||||
if (!owner) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const versions = await db
|
||||
.select(summary)
|
||||
.from(v.table)
|
||||
.where(eq(v.document, input.documentId))
|
||||
.orderBy(desc(v.createdAt))
|
||||
.limit(LIST_LIMIT);
|
||||
return versions as VersionSummary<TKind>[];
|
||||
},
|
||||
|
||||
async get(input: Owned & { versionId: string }) {
|
||||
const [version] = await db
|
||||
.select({ ...summary, data: v.data })
|
||||
.from(v.table)
|
||||
.where(ownedVersion(input));
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
return version as VersionSummary<TKind> & { data: unknown };
|
||||
},
|
||||
|
||||
/** Named versions are the user's own; only they can be renamed or deleted. */
|
||||
async rename(input: Owned & { versionId: string; name: string }) {
|
||||
const [version] = await db
|
||||
.update(v.table)
|
||||
.set({ name: input.name } as never)
|
||||
.where(and(ownedVersion(input), eq(v.kind, "named")))
|
||||
.returning(summary);
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
return version as VersionSummary<TKind>;
|
||||
},
|
||||
|
||||
async remove(input: Owned & { versionId: string }) {
|
||||
const [version] = await db
|
||||
.delete(v.table)
|
||||
.where(and(ownedVersion(input), eq(v.kind, "named")))
|
||||
.returning({ id: v.id });
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,183 +1,48 @@
|
||||
import type { DbOrTx } from "@reactive-resume/db/client";
|
||||
import type { ResumeVersionKind } from "@reactive-resume/db/schema";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import { createVersionHistory } from "../documents/version-history";
|
||||
import { parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation";
|
||||
|
||||
// An editing session's autosave is refreshed at most this often.
|
||||
const SESSION_REFRESH_MS = 2 * 60 * 1000;
|
||||
// Autosaves, AI edits and restore markers are kept this long. Named, sent, created, imported and
|
||||
// before-restore versions stay until deleted.
|
||||
const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
const EXPIRING_KINDS: ResumeVersionKind[] = ["auto", "ai", "restored"];
|
||||
// A safety cap on autosaves per resume, so storage stays bounded however often it's edited.
|
||||
const MAX_AUTOSAVES = 500;
|
||||
// History shows at most this many versions, newest first.
|
||||
const LIST_LIMIT = 100;
|
||||
const t = schema.resumeVersion;
|
||||
const history = createVersionHistory<ResumeVersionKind, ResumeData>({
|
||||
versions: { ...t, table: t, document: t.resumeId, documentKey: "resumeId" },
|
||||
owner: { table: schema.resume, id: schema.resume.id, userId: schema.resume.userId },
|
||||
// Named, sent, created, imported and before-restore versions stay until deleted.
|
||||
expiringKinds: ["auto", "ai", "restored"],
|
||||
toStored: parseWritableResumeData,
|
||||
label: "resume",
|
||||
});
|
||||
|
||||
const summary = {
|
||||
id: schema.resumeVersion.id,
|
||||
kind: schema.resumeVersion.kind,
|
||||
name: schema.resumeVersion.name,
|
||||
createdAt: schema.resumeVersion.createdAt,
|
||||
};
|
||||
type Owned = { resumeId: string; userId: string };
|
||||
const ids = ({ resumeId, ...rest }: Owned) => ({ ...rest, documentId: resumeId });
|
||||
|
||||
type VersionInput = {
|
||||
resumeId: string;
|
||||
userId: string;
|
||||
data: ResumeData;
|
||||
kind: ResumeVersionKind;
|
||||
name?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
export const writeVersion = (
|
||||
client: DbOrTx,
|
||||
input: Owned & { data: ResumeData; kind: ResumeVersionKind; name?: string; sessionId?: string },
|
||||
) =>
|
||||
history.write(client, {
|
||||
documentId: input.resumeId,
|
||||
userId: input.userId,
|
||||
data: input.data,
|
||||
kind: input.kind,
|
||||
name: input.name,
|
||||
sessionId: input.sessionId,
|
||||
});
|
||||
|
||||
export async function writeVersion(client: DbOrTx, input: VersionInput) {
|
||||
const [version] = await client
|
||||
.insert(schema.resumeVersion)
|
||||
.values({
|
||||
resumeId: input.resumeId,
|
||||
userId: input.userId,
|
||||
data: parseWritableResumeData(input.data),
|
||||
kind: input.kind,
|
||||
name: input.name ?? null,
|
||||
sessionId: input.sessionId ?? null,
|
||||
})
|
||||
.returning(summary);
|
||||
if (!version) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to save the version." });
|
||||
export const saveSessionVersion = (input: Owned & { data: ResumeData; sessionId?: string }) =>
|
||||
history.saveSession({ ...ids(input), data: input.data, sessionId: input.sessionId });
|
||||
|
||||
await pruneVersions(client, input.resumeId);
|
||||
return version;
|
||||
}
|
||||
export const listVersions = (input: Owned) => history.list(ids(input));
|
||||
|
||||
/** Retention runs when a resume gets a new version, so it needs no scheduler. */
|
||||
async function pruneVersions(client: DbOrTx, resumeId: string) {
|
||||
await client
|
||||
.delete(schema.resumeVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.resumeVersion.resumeId, resumeId),
|
||||
inArray(schema.resumeVersion.kind, EXPIRING_KINDS),
|
||||
lt(schema.resumeVersion.createdAt, new Date(Date.now() - RETENTION_MS)),
|
||||
),
|
||||
);
|
||||
|
||||
const newestAutosaves = client
|
||||
.select({ id: schema.resumeVersion.id })
|
||||
.from(schema.resumeVersion)
|
||||
.where(and(eq(schema.resumeVersion.resumeId, resumeId), eq(schema.resumeVersion.kind, "auto")))
|
||||
.orderBy(desc(schema.resumeVersion.createdAt))
|
||||
.limit(MAX_AUTOSAVES);
|
||||
|
||||
await client
|
||||
.delete(schema.resumeVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.resumeVersion.resumeId, resumeId),
|
||||
eq(schema.resumeVersion.kind, "auto"),
|
||||
notInArray(schema.resumeVersion.id, newestAutosaves),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The autosave path. Each editing session keeps one version holding its latest state, refreshed at most every
|
||||
* two minutes. Clients that send no session (API callers) get a new autosave once the newest version is two
|
||||
* minutes old. Best effort: it never fails or delays the save beyond its own queries.
|
||||
*/
|
||||
export async function saveSessionVersion(input: {
|
||||
resumeId: string;
|
||||
userId: string;
|
||||
data: ResumeData;
|
||||
sessionId?: string;
|
||||
}) {
|
||||
try {
|
||||
const [latest] = await db
|
||||
.select({ id: schema.resumeVersion.id, createdAt: schema.resumeVersion.createdAt })
|
||||
.from(schema.resumeVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.resumeVersion.resumeId, input.resumeId),
|
||||
...(input.sessionId
|
||||
? [eq(schema.resumeVersion.kind, "auto"), eq(schema.resumeVersion.sessionId, input.sessionId)]
|
||||
: []),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.resumeVersion.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (latest && Date.now() - latest.createdAt.getTime() < SESSION_REFRESH_MS) return;
|
||||
|
||||
if (latest && input.sessionId) {
|
||||
await db
|
||||
.update(schema.resumeVersion)
|
||||
.set({ data: parseWritableResumeData(input.data), createdAt: new Date() })
|
||||
.where(eq(schema.resumeVersion.id, latest.id));
|
||||
return;
|
||||
}
|
||||
|
||||
await writeVersion(db, { ...input, kind: "auto" });
|
||||
} catch (error) {
|
||||
console.warn("Failed to save the session's version:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const ownedVersion = (input: { resumeId: string; userId: string; versionId: string }) =>
|
||||
and(
|
||||
eq(schema.resumeVersion.id, input.versionId),
|
||||
eq(schema.resumeVersion.resumeId, input.resumeId),
|
||||
eq(schema.resumeVersion.userId, input.userId),
|
||||
);
|
||||
|
||||
async function assertOwnsResume(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");
|
||||
}
|
||||
|
||||
export async function listVersions(input: { resumeId: string; userId: string }) {
|
||||
await assertOwnsResume(input);
|
||||
|
||||
return db
|
||||
.select(summary)
|
||||
.from(schema.resumeVersion)
|
||||
.where(eq(schema.resumeVersion.resumeId, input.resumeId))
|
||||
.orderBy(desc(schema.resumeVersion.createdAt))
|
||||
.limit(LIST_LIMIT);
|
||||
}
|
||||
|
||||
export async function getVersion(input: { resumeId: string; userId: string; versionId: string }) {
|
||||
const [version] = await db
|
||||
.select({ ...summary, data: schema.resumeVersion.data })
|
||||
.from(schema.resumeVersion)
|
||||
.where(ownedVersion(input));
|
||||
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
export const getVersion = async (input: Owned & { versionId: string }) => {
|
||||
const version = await history.get({ ...ids(input), versionId: input.versionId });
|
||||
return { ...version, data: parseStoredResumeData(version.data) };
|
||||
}
|
||||
};
|
||||
|
||||
/** Named versions are the user's own; only they can be renamed or deleted. */
|
||||
export async function renameVersion(input: { resumeId: string; userId: string; versionId: string; name: string }) {
|
||||
const [version] = await db
|
||||
.update(schema.resumeVersion)
|
||||
.set({ name: input.name })
|
||||
.where(and(ownedVersion(input), eq(schema.resumeVersion.kind, "named")))
|
||||
.returning(summary);
|
||||
export const renameVersion = (input: Owned & { versionId: string; name: string }) =>
|
||||
history.rename({ ...ids(input), versionId: input.versionId, name: input.name });
|
||||
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
return version;
|
||||
}
|
||||
|
||||
export async function deleteVersion(input: { resumeId: string; userId: string; versionId: string }) {
|
||||
const [version] = await db
|
||||
.delete(schema.resumeVersion)
|
||||
.where(and(ownedVersion(input), eq(schema.resumeVersion.kind, "named")))
|
||||
.returning({ id: schema.resumeVersion.id });
|
||||
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
}
|
||||
export const deleteVersion = (input: Owned & { versionId: string }) =>
|
||||
history.remove({ ...ids(input), versionId: input.versionId });
|
||||
|
||||
Reference in New Issue
Block a user