feat(api): one documents library with Trash, job links and automatic names

Schema: resume gains application_id (the job a copy was made for),
trashed_at and auto_name; cover_letter gains tags, is_locked and
trashed_at.

A new documents router treats resumes and saved letters as one library:
list (live or in Trash, with the linked application), counts, rename,
setTags, setLocked, linkApplication, trash, restore, purge (only from
Trash) and copyForJob, which duplicates a resume, links the copy to the
application and gives the application the copy when it has none.

- resume.delete and coverLetters.delete now move to Trash. Documents stay
  there for 30 days and are purged when their owner next lists documents,
  so no scheduler is needed.
- Documents in Trash are left out of resume and letter lists, and a resume
  in Trash isn't shared: getBySlug and verifyPassword skip it.
- Locked documents can't be moved to Trash; locked letters can't be edited.
- A blank resume created with autoName takes its headline as its name
  until someone renames it.
This commit is contained in:
Amruth Pillai
2026-09-28 19:59:03 +02:00
parent dc6de786f7
commit a568f64b43
16 changed files with 7476 additions and 21 deletions
@@ -0,0 +1,7 @@
ALTER TABLE "cover_letter" ADD COLUMN "tags" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "cover_letter" ADD COLUMN "is_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "cover_letter" ADD COLUMN "trashed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "resume" ADD COLUMN "application_id" text;--> statement-breakpoint
ALTER TABLE "resume" ADD COLUMN "trashed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "resume" ADD COLUMN "auto_name" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "resume" ADD CONSTRAINT "resume_application_id_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "application"("id") ON DELETE SET NULL;
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
import z from "zod";
const documentTypeSchema = z.enum(["resume", "letter"]).describe("resume, or letter (a saved cover letter).");
const documentRefSchema = z.object({
type: documentTypeSchema,
id: z.string().min(1).describe("The ID of the resume or cover letter."),
});
const documentSchema = z.object({
type: documentTypeSchema,
id: z.string(),
name: z.string(),
tags: z.array(z.string()),
isLocked: z.boolean(),
trashedAt: z.date().nullable().describe("When the document moved to Trash; null while it's live."),
createdAt: z.date(),
updatedAt: z.date(),
application: z
.object({ id: z.string(), company: z.string(), role: z.string() })
.nullable()
.describe("The job application this document was made for, if any."),
});
export type DocumentSummary = z.infer<typeof documentSchema>;
export const documentsDto = {
list: {
input: z
.object({ trashed: z.boolean().default(false).describe("List the documents in Trash instead.") })
.default({ trashed: false }),
output: z.array(documentSchema),
},
counts: {
input: z.void(),
output: z.object({ resume: z.number(), letter: z.number(), trash: z.number() }),
},
rename: {
input: documentRefSchema.extend({ name: z.string().trim().min(1).max(100) }),
output: z.void(),
},
setTags: {
input: documentRefSchema.extend({ tags: z.array(z.string().trim().min(1).max(40)).max(20) }),
output: z.void(),
},
setLocked: {
input: documentRefSchema.extend({ isLocked: z.boolean() }),
output: z.void(),
},
linkApplication: {
input: documentRefSchema.extend({
applicationId: z.string().min(1).nullable().describe("The application to link, or null to unlink."),
}),
output: z.void(),
},
trash: { input: documentRefSchema, output: z.void() },
restore: { input: documentRefSchema, output: z.void() },
purge: { input: documentRefSchema, output: z.void() },
copyForJob: {
input: z.object({
resumeId: z.string().min(1).describe("The resume to copy."),
applicationId: z.string().min(1).optional().describe("The job the copy is for; omit for no job yet."),
name: z.string().trim().min(1).max(100).optional().describe("Defaults to “{source} — {company}”."),
}),
output: z.string().describe("The ID of the copy."),
},
};
+8 -1
View File
@@ -18,7 +18,10 @@ const resumeSchema = createSelectSchema(schema.resume, {
userId: z.string().describe("The ID of the user who owns the resume."),
createdAt: z.date().describe("The date and time the resume was created."),
updatedAt: z.date().describe("The date and time the resume was last updated."),
});
})
// Document-library columns (Trash, the application a copy was made for, automatic naming) stay internal to
// the documents API.
.omit({ applicationId: true, trashedAt: true, autoName: true });
const versionSchema = z.object({
id: z.string().describe("The ID of the version."),
@@ -62,6 +65,10 @@ export const resumeDto = {
create: {
input: resumeSchema.pick({ name: true, tags: true }).extend({
autoName: z
.boolean()
.optional()
.describe("While true, the resume's name follows its headline until someone renames it."),
slug: resumeSchema.shape.slug
.optional()
.describe("The slug of the resume. Generated from the name, and made unique, when omitted."),
@@ -88,9 +88,10 @@ export const coverLettersRouter = {
path: "/cover-letters/{id}",
tags: ["Cover Letters"],
operationId: "deleteCoverLetter",
summary: "Delete a cover letter",
description: "Permanently deletes a saved cover letter using its expected revision.",
successDescription: "The cover letter was deleted successfully.",
summary: "Move a cover letter to Trash",
description:
"Moves a saved cover letter to Trash using its expected revision. It stays there for 30 days, then it is deleted; until then it can be restored (documents.restore) or deleted at once (documents.purge). Locked letters can't be moved.",
successDescription: "The cover letter is in Trash.",
})
.input(coverLetterDto.delete.input)
.output(coverLetterDto.delete.output)
@@ -2,7 +2,7 @@ import type { CoverLetter, CoverLetterDocument, CoverLetterStyle } from "@reacti
import type { Template } from "@reactive-resume/schema/templates";
import type { CoverLetterListInput, CoverLetterUpdateInput } from "../../dto/cover-letter";
import { ORPCError } from "@orpc/client";
import { and, count, desc, eq, ilike, sql } from "drizzle-orm";
import { and, count, desc, eq, ilike, isNull, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { copyCoverLetterStyle } from "@reactive-resume/resume/cover-letter";
@@ -89,18 +89,29 @@ async function updateRevision(
eq(schema.coverLetter.id, input.id),
eq(schema.coverLetter.userId, input.userId),
eq(schema.coverLetter.revision, input.expectedRevision),
eq(schema.coverLetter.isLocked, false),
),
)
.returning();
if (row) return coverLetterSchema.parse(row);
await getById(input);
await assertUnlocked(input);
throw new ORPCError("CONFLICT", { message: "This cover letter changed elsewhere. Reload it before saving again." });
}
/** Locked letters, like locked resumes, can't be edited or moved to Trash. */
async function assertUnlocked(input: OwnedId) {
const [row] = await db
.select({ isLocked: schema.coverLetter.isLocked })
.from(schema.coverLetter)
.where(and(eq(schema.coverLetter.id, input.id), eq(schema.coverLetter.userId, input.userId)));
if (!row) throw new ORPCError("NOT_FOUND");
if (row.isLocked) throw new ORPCError("DOCUMENT_LOCKED", { status: 400, message: "Unlock the letter first." });
}
export const coverLetterService = {
getById,
list: async (input: CoverLetterListInput & { userId: string }) => {
const filters = [eq(schema.coverLetter.userId, input.userId)];
const filters = [eq(schema.coverLetter.userId, input.userId), isNull(schema.coverLetter.trashedAt)];
if (input.resumeId) filters.push(eq(schema.coverLetter.sourceResumeId, input.resumeId));
if (input.applicationId) filters.push(eq(schema.coverLetter.sourceApplicationId, input.applicationId));
if (input.search?.trim())
@@ -155,19 +166,22 @@ export const coverLetterService = {
const letter = await getById(input);
return insert({ ...letter, userId: input.userId, name: input.name ?? `${letter.name} (copy)`.slice(0, 100) });
},
/** Moves the letter to Trash (30 days, then deleted); Trash offers Restore and Delete now. */
delete: async (input: RevisionInput): Promise<void> => {
const rows = await db
.delete(schema.coverLetter)
.update(schema.coverLetter)
.set({ trashedAt: new Date(), revision: sql`${schema.coverLetter.revision} + 1` })
.where(
and(
eq(schema.coverLetter.id, input.id),
eq(schema.coverLetter.userId, input.userId),
eq(schema.coverLetter.revision, input.expectedRevision),
eq(schema.coverLetter.isLocked, false),
),
)
.returning({ id: schema.coverLetter.id });
if (rows.length) return;
await getById(input);
await assertUnlocked(input);
throw new ORPCError("CONFLICT", { message: "This cover letter changed elsewhere. Reload it before deleting." });
},
copyEmbedded: async (input: {
@@ -0,0 +1,161 @@
import { protectedProcedure } from "../../context";
import { documentsDto } from "../../dto/documents";
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
import { documentsService } from "./service";
const route = (
method: "GET" | "POST",
path: `/${string}`,
operationId: string,
summary: string,
description: string,
) => ({
method,
path,
tags: ["Documents"],
operationId,
summary,
description: `${description} Requires authentication.`,
});
/** Resumes and saved cover letters as one library: list, rename, tag, lock, link, Trash and Copy for a job. */
export const documentsRouter = {
list: protectedProcedure
.route(
route(
"GET",
"/documents",
"listDocuments",
"List documents",
"Returns the user's resumes and saved cover letters (or those in Trash), newest edit first, with the job application each was made for. Documents in Trash for more than 30 days are deleted first.",
),
)
.input(documentsDto.list.input)
.output(documentsDto.list.output)
.handler(({ context, input }) => documentsService.list({ userId: context.user.id, trashed: input.trashed })),
counts: protectedProcedure
.route(
route(
"GET",
"/documents/counts",
"countDocuments",
"Count documents",
"Counts live resumes and letters, and everything in Trash.",
),
)
.output(documentsDto.counts.output)
.handler(({ context }) => documentsService.counts({ userId: context.user.id })),
rename: protectedProcedure
.route(route("POST", "/documents/rename", "renameDocument", "Rename a document", "Renames a resume or letter."))
.input(documentsDto.rename.input)
.use(resumeMutationRateLimit)
.output(documentsDto.rename.output)
.handler(({ context, input }) => documentsService.rename({ ...input, userId: context.user.id })),
setTags: protectedProcedure
.route(
route(
"POST",
"/documents/tags",
"setDocumentTags",
"Set a document's tags",
"Replaces a resume's or letter's tags.",
),
)
.input(documentsDto.setTags.input)
.use(resumeMutationRateLimit)
.output(documentsDto.setTags.output)
.handler(({ context, input }) => documentsService.setTags({ ...input, userId: context.user.id })),
setLocked: protectedProcedure
.route(
route(
"POST",
"/documents/lock",
"lockDocument",
"Lock or unlock a document",
"Locks a resume or letter against edits and Trash, or unlocks it.",
),
)
.input(documentsDto.setLocked.input)
.use(resumeMutationRateLimit)
.output(documentsDto.setLocked.output)
.handler(({ context, input }) => documentsService.setLocked({ ...input, userId: context.user.id })),
linkApplication: protectedProcedure
.route(
route(
"POST",
"/documents/application",
"linkDocumentApplication",
"Link a document to an application",
"Records the job application a resume or letter was made for, or clears it.",
),
)
.input(documentsDto.linkApplication.input)
.use(resumeMutationRateLimit)
.output(documentsDto.linkApplication.output)
.handler(({ context, input }) => documentsService.linkApplication({ ...input, userId: context.user.id })),
trash: protectedProcedure
.route(
route(
"POST",
"/documents/trash",
"trashDocument",
"Move a document to Trash",
"Moves a resume or letter to Trash, where it stays for 30 days. A resume in Trash isn't shared. Locked documents can't be moved.",
),
)
.input(documentsDto.trash.input)
.use(resumeMutationRateLimit)
.output(documentsDto.trash.output)
.handler(({ context, input }) => documentsService.trash({ ...input, userId: context.user.id })),
restore: protectedProcedure
.route(
route(
"POST",
"/documents/restore",
"restoreDocument",
"Restore a document",
"Brings a document back from Trash.",
),
)
.input(documentsDto.restore.input)
.use(resumeMutationRateLimit)
.output(documentsDto.restore.output)
.handler(({ context, input }) => documentsService.restore({ ...input, userId: context.user.id })),
purge: protectedProcedure
.route(
route(
"POST",
"/documents/purge",
"purgeDocument",
"Delete a document now",
"Permanently deletes a document that is already in Trash.",
),
)
.input(documentsDto.purge.input)
.use(resumeMutationRateLimit)
.output(documentsDto.purge.output)
.handler(({ context, input }) => documentsService.purge({ ...input, userId: context.user.id })),
copyForJob: protectedProcedure
.route(
route(
"POST",
"/documents/copy-for-job",
"copyResumeForJob",
"Copy a resume for a job",
"Duplicates a resume and links the copy to a job application; the application gets the copy if it has no resume yet.",
),
)
.input(documentsDto.copyForJob.input)
.use(resumeMutationRateLimit)
.output(documentsDto.copyForJob.output)
.handler(({ context, input }) => documentsService.copyForJob({ ...input, userId: context.user.id })),
};
@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const dbMock = vi.hoisted(() => ({ select: vi.fn(), update: vi.fn(), delete: vi.fn() }));
const resumeServiceMock = vi.hoisted(() => ({
getById: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
setLocked: vi.fn(),
}));
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
vi.mock("../resume/service", () => ({ resumeService: resumeServiceMock }));
const { documentsService, suggestCopyName } = await import("./service");
// `select().from().where()` (optionally `.leftJoin()` first) resolving to `rows`.
const rows = (result: unknown[]) => {
const where = () => Promise.resolve(result);
return { from: () => ({ where, leftJoin: () => ({ where }) }) };
};
const updates = () => {
const set = vi.fn((_changes: Record<string, unknown>) => ({
where: () => Object.assign(Promise.resolve(), { returning: async () => [{ id: "x" }] }),
}));
dbMock.update.mockReturnValue({ set });
return set;
};
beforeEach(() => {
for (const mock of [...Object.values(dbMock), ...Object.values(resumeServiceMock)]) mock.mockReset();
dbMock.delete.mockReturnValue({ where: async () => undefined });
});
describe("suggestCopyName", () => {
it("uses the source's base name and the company, or marks a plain copy", () => {
expect(suggestCopyName("Product Designer — Lumen", "Orbital")).toBe("Product Designer — Orbital");
expect(suggestCopyName("Product Designer")).toBe("Product Designer (copy)");
});
});
describe("Trash", () => {
it("won't move a locked document, and keeps the resume's error code", async () => {
dbMock.select.mockReturnValueOnce(rows([{ isLocked: true, trashedAt: null }]));
await expect(documentsService.trash({ userId: "u1", type: "resume", id: "r1" })).rejects.toMatchObject({
code: "RESUME_LOCKED",
});
expect(dbMock.update).not.toHaveBeenCalled();
});
it("moves an unlocked letter to Trash and moves its revision on", async () => {
dbMock.select.mockReturnValueOnce(rows([{ isLocked: false, trashedAt: null }]));
const set = updates();
await documentsService.trash({ userId: "u1", type: "letter", id: "l1" });
expect(set).toHaveBeenCalledWith(
expect.objectContaining({ trashedAt: expect.any(Date), revision: expect.anything() }),
);
});
it("deletes for good only what is already in Trash", async () => {
dbMock.select.mockReturnValueOnce(rows([{ isLocked: false, trashedAt: null }]));
await expect(documentsService.purge({ userId: "u1", type: "resume", id: "r1" })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
dbMock.select.mockReturnValueOnce(rows([{ isLocked: false, trashedAt: new Date() }]));
await documentsService.purge({ userId: "u1", type: "resume", id: "r1" });
expect(resumeServiceMock.delete).toHaveBeenCalledWith({ id: "r1", userId: "u1" });
});
});
describe("list", () => {
it("purges documents trashed over 30 days ago, then merges resumes and letters by last edit", async () => {
const day = (n: number) => new Date(2026, 8, n);
dbMock.select
// Expired Trash: one old resume, no letters.
.mockReturnValueOnce(rows([{ id: "old" }]))
.mockReturnValueOnce(rows([]))
// The live documents.
.mockReturnValueOnce(
rows([
{
id: "r1",
name: "Resume",
tags: [],
isLocked: false,
trashedAt: null,
createdAt: day(1),
updatedAt: day(2),
applicationId: "a1",
company: "Lumen",
role: "Designer",
},
]),
)
.mockReturnValueOnce(
rows([
{
id: "l1",
name: "Letter",
tags: [],
isLocked: false,
trashedAt: null,
createdAt: day(1),
updatedAt: day(5),
applicationId: null,
company: null,
role: null,
},
]),
);
const documents = await documentsService.list({ userId: "u1", trashed: false });
expect(resumeServiceMock.delete).toHaveBeenCalledWith({ id: "old", userId: "u1" });
expect(documents.map((document) => [document.type, document.id])).toEqual([
["letter", "l1"],
["resume", "r1"],
]);
expect(documents[1]?.application).toEqual({ id: "a1", company: "Lumen", role: "Designer" });
expect(documents[0]?.application).toBeNull();
});
});
describe("copyForJob", () => {
const source = {
name: "Product Designer — Lumen",
tags: ["design"],
data: { metadata: { page: { locale: "en-US" } } },
};
it("links the copy to the job, and gives the job the copy when it has no resume", async () => {
resumeServiceMock.getById.mockResolvedValueOnce(source);
dbMock.select.mockReturnValueOnce(rows([{ id: "a1", company: "Orbital", resumeId: null }]));
resumeServiceMock.create.mockResolvedValueOnce("copy");
const set = updates();
const id = await documentsService.copyForJob({ userId: "u1", resumeId: "r1", applicationId: "a1" });
expect(id).toBe("copy");
expect(resumeServiceMock.create).toHaveBeenCalledWith(
expect.objectContaining({ name: "Product Designer — Orbital", tags: ["design"] }),
);
expect(set).toHaveBeenCalledWith({ applicationId: "a1" });
expect(set).toHaveBeenCalledWith({ resumeId: "copy" });
});
it("leaves an application's existing resume alone", async () => {
resumeServiceMock.getById.mockResolvedValueOnce(source);
dbMock.select.mockReturnValueOnce(rows([{ id: "a1", company: "Orbital", resumeId: "base" }]));
resumeServiceMock.create.mockResolvedValueOnce("copy");
const set = updates();
await documentsService.copyForJob({ userId: "u1", resumeId: "r1", applicationId: "a1", name: "Mine" });
expect(resumeServiceMock.create).toHaveBeenCalledWith(expect.objectContaining({ name: "Mine" }));
expect(set).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,251 @@
import type { DocumentSummary } from "../../dto/documents";
import { ORPCError } from "@orpc/client";
import { and, count, eq, isNotNull, isNull, lt, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { resumeService } from "../resume/service";
type DocumentType = DocumentSummary["type"];
type DocumentRef = { userId: string; type: DocumentType; id: string };
// Trash keeps documents this long before deleting them for good.
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
// Resumes keep their existing error code, which API and MCP clients already handle.
const locked = (type: DocumentType) =>
type === "resume"
? new ORPCError("RESUME_LOCKED")
: new ORPCError("DOCUMENT_LOCKED", { status: 400, message: "Unlock the letter first." });
/** "{source} — {company}" for a copy made for a job, "{source} (copy)" without one. */
export function suggestCopyName(sourceName: string, company?: string) {
const base = sourceName.split(" — ")[0]?.trim() || sourceName;
return (company ? `${base} — ${company}` : `${base} (copy)`).slice(0, 100);
}
async function assertOwnedApplication(userId: string, applicationId: string) {
const [application] = await db
.select({ id: schema.application.id, company: schema.application.company, resumeId: schema.application.resumeId })
.from(schema.application)
.where(and(eq(schema.application.id, applicationId), eq(schema.application.userId, userId)));
if (!application) throw new ORPCError("NOT_FOUND", { message: "Application not found." });
return application;
}
/** A resume's or letter's row in its own table, scoped to its owner. */
const owned = ({ type, id, userId }: DocumentRef) =>
type === "resume"
? and(eq(schema.resume.id, id), eq(schema.resume.userId, userId))
: and(eq(schema.coverLetter.id, id), eq(schema.coverLetter.userId, userId));
type ResumeChanges = Partial<typeof schema.resume.$inferInsert>;
type LetterChanges = Partial<typeof schema.coverLetter.$inferInsert>;
/** The same change to either table; `letter` differs only where the columns do (the linked application). */
async function update(ref: DocumentRef, resume: ResumeChanges, letter: LetterChanges = resume as LetterChanges) {
const rows =
ref.type === "resume"
? await db.update(schema.resume).set(resume).where(owned(ref)).returning({ id: schema.resume.id })
: await db
.update(schema.coverLetter)
// Letters carry a revision for their editor's optimistic saves; any change moves it on.
.set({ ...letter, revision: sql`${schema.coverLetter.revision} + 1` })
.where(owned(ref))
.returning({ id: schema.coverLetter.id });
if (rows.length === 0) throw new ORPCError("NOT_FOUND");
}
async function readState(ref: DocumentRef) {
const [row] =
ref.type === "resume"
? await db
.select({ isLocked: schema.resume.isLocked, trashedAt: schema.resume.trashedAt })
.from(schema.resume)
.where(owned(ref))
: await db
.select({ isLocked: schema.coverLetter.isLocked, trashedAt: schema.coverLetter.trashedAt })
.from(schema.coverLetter)
.where(owned(ref));
if (!row) throw new ORPCError("NOT_FOUND");
return row;
}
/** Deletes for good: resumes through their own path (storage too), letters directly. */
async function deleteForGood(ref: DocumentRef) {
if (ref.type === "resume") return resumeService.delete({ id: ref.id, userId: ref.userId });
await db.delete(schema.coverLetter).where(owned(ref));
}
/** Documents trashed more than 30 days ago go for good. It runs when the user lists documents, so no scheduler. */
async function purgeExpired(userId: string) {
const cutoff = new Date(Date.now() - TRASH_RETENTION_MS);
const [resumes, letters] = await Promise.all([
db
.select({ id: schema.resume.id })
.from(schema.resume)
.where(and(eq(schema.resume.userId, userId), lt(schema.resume.trashedAt, cutoff))),
db
.select({ id: schema.coverLetter.id })
.from(schema.coverLetter)
.where(and(eq(schema.coverLetter.userId, userId), lt(schema.coverLetter.trashedAt, cutoff))),
]);
await Promise.allSettled([
...resumes.map(({ id }) => deleteForGood({ userId, type: "resume", id })),
...letters.map(({ id }) => deleteForGood({ userId, type: "letter", id })),
]);
}
export const documentsService = {
/** Every live resume and letter (or everything in Trash), newest edit first. The app filters and sorts them. */
list: async (input: { userId: string; trashed: boolean }): Promise<DocumentSummary[]> => {
await purgeExpired(input.userId);
const [resumes, letters] = await Promise.all([
db
.select({
id: schema.resume.id,
name: schema.resume.name,
tags: schema.resume.tags,
isLocked: schema.resume.isLocked,
trashedAt: schema.resume.trashedAt,
createdAt: schema.resume.createdAt,
updatedAt: schema.resume.updatedAt,
applicationId: schema.application.id,
company: schema.application.company,
role: schema.application.role,
})
.from(schema.resume)
.leftJoin(schema.application, eq(schema.resume.applicationId, schema.application.id))
.where(
and(
eq(schema.resume.userId, input.userId),
input.trashed ? isNotNull(schema.resume.trashedAt) : isNull(schema.resume.trashedAt),
),
),
db
.select({
id: schema.coverLetter.id,
name: schema.coverLetter.name,
tags: schema.coverLetter.tags,
isLocked: schema.coverLetter.isLocked,
trashedAt: schema.coverLetter.trashedAt,
createdAt: schema.coverLetter.createdAt,
updatedAt: schema.coverLetter.updatedAt,
applicationId: schema.application.id,
company: schema.application.company,
role: schema.application.role,
})
.from(schema.coverLetter)
.leftJoin(schema.application, eq(schema.coverLetter.sourceApplicationId, schema.application.id))
.where(
and(
eq(schema.coverLetter.userId, input.userId),
input.trashed ? isNotNull(schema.coverLetter.trashedAt) : isNull(schema.coverLetter.trashedAt),
),
),
]);
const toSummary =
(type: DocumentType) =>
({ applicationId, company, role, ...row }: (typeof resumes)[number]): DocumentSummary => ({
type,
...row,
application: applicationId ? { id: applicationId, company: company ?? "", role: role ?? "" } : null,
});
return [...resumes.map(toSummary("resume")), ...letters.map(toSummary("letter"))].sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
);
},
counts: async (input: { userId: string }) => {
const [[resumes], [letters], [trashedResumes], [trashedLetters]] = await Promise.all([
db
.select({ total: count() })
.from(schema.resume)
.where(and(eq(schema.resume.userId, input.userId), isNull(schema.resume.trashedAt))),
db
.select({ total: count() })
.from(schema.coverLetter)
.where(and(eq(schema.coverLetter.userId, input.userId), isNull(schema.coverLetter.trashedAt))),
db
.select({ total: count() })
.from(schema.resume)
.where(and(eq(schema.resume.userId, input.userId), isNotNull(schema.resume.trashedAt))),
db
.select({ total: count() })
.from(schema.coverLetter)
.where(and(eq(schema.coverLetter.userId, input.userId), isNotNull(schema.coverLetter.trashedAt))),
]);
return {
resume: resumes?.total ?? 0,
letter: letters?.total ?? 0,
trash: (trashedResumes?.total ?? 0) + (trashedLetters?.total ?? 0),
};
},
/** A name typed by hand ends a blank resume's automatic naming. */
rename: (input: DocumentRef & { name: string }) =>
update(input, { name: input.name, autoName: false }, { name: input.name }),
setTags: (input: DocumentRef & { tags: string[] }) => update(input, { tags: [...new Set(input.tags)] }),
setLocked: (input: DocumentRef & { isLocked: boolean }) =>
input.type === "resume"
? resumeService.setLocked({ id: input.id, userId: input.userId, isLocked: input.isLocked })
: update(input, { isLocked: input.isLocked }),
linkApplication: async (input: DocumentRef & { applicationId: string | null }) => {
if (input.applicationId) await assertOwnedApplication(input.userId, input.applicationId);
await update(input, { applicationId: input.applicationId }, { sourceApplicationId: input.applicationId });
},
/** Undoable: Trash hides the document and stops its public link; Restore brings it back intact. */
trash: async (input: DocumentRef) => {
const state = await readState(input);
if (state.isLocked) throw locked(input.type);
await update(input, { trashedAt: new Date() });
},
restore: (input: DocumentRef) => update(input, { trashedAt: null }),
/** "Delete now…": only for documents already in Trash. */
purge: async (input: DocumentRef) => {
const state = await readState(input);
if (!state.trashedAt) throw new ORPCError("BAD_REQUEST", { message: "Move the document to Trash first." });
await deleteForGood(input);
},
/** Duplicates a resume for a job: the copy is linked to the application, which gets it if it has no resume yet. */
copyForJob: async (input: {
userId: string;
resumeId: string;
applicationId?: string | undefined;
name?: string | undefined;
}) => {
const source = await resumeService.getById({ id: input.resumeId, userId: input.userId });
const application = input.applicationId ? await assertOwnedApplication(input.userId, input.applicationId) : null;
const id = await resumeService.create({
userId: input.userId,
name: input.name ?? suggestCopyName(source.name, application?.company),
tags: source.tags,
locale: source.data.metadata.page.locale as never,
data: source.data,
});
if (application) {
await db.update(schema.resume).set({ applicationId: application.id }).where(eq(schema.resume.id, id));
if (!application.resumeId) {
await db.update(schema.application).set({ resumeId: id }).where(eq(schema.application.id, application.id));
}
}
return id;
},
};
+6 -4
View File
@@ -2,6 +2,7 @@ import { generateId, generateRandomName } from "@reactive-resume/utils/string";
import { protectedProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
import { documentsService } from "../documents/service";
import { createResumeData } from "./initial-data";
import { parseStoredResumeData } from "./resume-data-validation";
import { resumeService } from "./service";
@@ -67,6 +68,7 @@ export const crudRouter = {
resumeService.create({
name: input.name,
...(input.slug ? { slug: input.slug } : {}),
...(input.autoName ? { autoName: true } : {}),
tags: input.tags,
locale: context.locale,
userId: context.user.id,
@@ -236,13 +238,13 @@ export const crudRouter = {
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "deleteResume",
summary: "Delete a resume",
summary: "Move a resume to Trash",
description:
"Permanently deletes a resume and its associated files (screenshots, PDFs) from storage. Locked resumes cannot be deleted; unlock the resume first. Requires authentication.",
successDescription: "The resume and its associated files were deleted successfully.",
"Moves a resume to Trash, which stops its public link. It stays there for 30 days, then it and its files are deleted; until then it can be restored (documents.restore) or deleted at once (documents.purge). Locked resumes can't be moved; unlock the resume first. Requires authentication.",
successDescription: "The resume is in Trash.",
})
.input(resumeDto.delete.input)
.use(resumeMutationRateLimit)
.output(resumeDto.delete.output)
.handler(({ context, input }) => resumeService.delete({ id: input.id, userId: context.user.id })),
.handler(({ context, input }) => documentsService.trash({ type: "resume", id: input.id, userId: context.user.id })),
};
@@ -67,6 +67,7 @@ vi.mock("drizzle-orm", () => ({
eq: (...a: unknown[]) => a,
gte: (...a: unknown[]) => a,
isNotNull: (...a: unknown[]) => a,
isNull: (...a: unknown[]) => a,
notInArray: (...a: unknown[]) => a,
sql: Object.assign((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }), {
join: (values: unknown[]) => values,
@@ -513,10 +514,25 @@ describe("update", () => {
const { update } = updateHarness({ slug: "Legacy_Slug" });
await resumeService.update({ id: "r1", userId: "u1", name: "Renamed", slug: "Legacy_Slug" });
expect(update.set).toHaveBeenCalledWith({ name: "Renamed", slug: "Legacy_Slug" });
// A name typed by hand also ends automatic naming.
expect(update.set).toHaveBeenCalledWith({ name: "Renamed", autoName: false, slug: "Legacy_Slug" });
expect(recordSlugChangeMock).not.toHaveBeenCalled();
});
it("names a blank resume after its headline until someone renames it", async () => {
const data = structuredClone(defaultResumeData);
data.basics.headline = " Product Designer ";
const select = createLockedSelectChain([{ data: defaultResumeData, isLocked: false, slug: "s", autoName: true }]);
const update = createUpdateChain([{ ...createResumeRow(data), name: "Product Designer" }]);
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
callback({ select: () => select.chain, update: () => update.chain }),
);
await resumeService.update({ id: "r1", userId: "u1", data });
expect(update.set).toHaveBeenCalledWith(expect.objectContaining({ name: "Product Designer" }));
});
it("passes the editing session to the autosave version, and skips it for restores", async () => {
updateHarness({ slug: "s" });
await resumeService.update({ id: "r1", userId: "u1", data: defaultResumeData, sessionId: "visit-1" });
+22 -4
View File
@@ -4,7 +4,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, gte, isNotNull, sql } from "drizzle-orm";
import { and, arrayContains, asc, desc, eq, gte, isNotNull, isNull, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { db } from "@reactive-resume/db/client";
@@ -415,6 +415,7 @@ export const resumeService = {
.where(
and(
eq(schema.resume.userId, input.userId),
isNull(schema.resume.trashedAt),
match(input.tags.length)
.with(0, () => undefined)
.otherwise(() => arrayContains(schema.resume.tags, input.tags)),
@@ -475,7 +476,7 @@ export const resumeService = {
})
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(and(matchesSlug(input.slug), eq(schema.user.username, input.username)))
.where(and(matchesSlug(input.slug), eq(schema.user.username, input.username), isNull(schema.resume.trashedAt)))
// A resume's current slug wins over another's redirect (renames delete clashing redirects anyway).
.orderBy(desc(sql`${schema.resume.slug} = ${input.slug}`))
.limit(1);
@@ -519,6 +520,8 @@ export const resumeService = {
data?: ResumeData;
/** The first version in History: "created", or "import" for an imported document. */
origin?: "created" | "import";
/** The name follows the headline until someone renames the resume. */
autoName?: boolean;
}) => {
const id = input.id ?? generateId();
const data = parseWritableResumeData(structuredClone(input.data ?? defaultResumeData));
@@ -529,6 +532,7 @@ export const resumeService = {
await db.insert(schema.resume).values({
id,
name: input.name,
autoName: input.autoName ?? false,
slug,
tags: input.tags,
userId: input.userId,
@@ -581,6 +585,7 @@ export const resumeService = {
data: schema.resume.data,
slug: schema.resume.slug,
isLocked: schema.resume.isLocked,
autoName: schema.resume.autoName,
})
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
@@ -606,8 +611,14 @@ export const resumeService = {
const normalizedData = input.data
? parseWritableResumeData(input.data, parseStoredResumeData(existing.data))
: undefined;
// A blank resume is named after its headline until someone names it by hand.
const followedName =
existing.autoName && input.name === undefined && normalizedData
? normalizedData.basics.headline.trim().slice(0, 100) || undefined
: undefined;
const updateData: Partial<typeof schema.resume.$inferSelect> = {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.name !== undefined ? { name: input.name, autoName: false } : {}),
...(followedName ? { name: followedName } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(normalizedData ? { data: normalizedData } : {}),
@@ -742,7 +753,14 @@ export const resumeService = {
.select({ id: schema.resume.id, password: schema.resume.password })
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(and(isNotNull(schema.resume.password), matchesSlug(input.slug), eq(schema.user.username, input.username)))
.where(
and(
isNotNull(schema.resume.password),
matchesSlug(input.slug),
eq(schema.user.username, input.username),
isNull(schema.resume.trashedAt),
),
)
.orderBy(desc(sql`${schema.resume.slug} = ${input.slug}`))
.limit(1);
+2
View File
@@ -4,6 +4,7 @@ import { aiProvidersRouter } from "../features/ai-providers/router";
import { applicationsRouter } from "../features/applications/router";
import { authRouter } from "../features/auth/router";
import { coverLettersRouter } from "../features/cover-letters/router";
import { documentsRouter } from "../features/documents/router";
import { flagsRouter } from "../features/flags/router";
import { resumeRouter } from "../features/resume/router";
import { statisticsRouter } from "../features/statistics/router";
@@ -16,6 +17,7 @@ export default {
applications: applicationsRouter,
auth: authRouter,
coverLetters: coverLettersRouter,
documents: documentsRouter,
flags: flagsRouter,
resume: resumeRouter,
statistics: statisticsRouter,
+4
View File
@@ -23,6 +23,10 @@ export const coverLetter = pg.pgTable(
style: pg.jsonb("style").$type<CoverLetterStyle>().notNull(),
sourceResumeId: pg.text("source_resume_id").references(() => resume.id, { onDelete: "set null" }),
sourceApplicationId: pg.text("source_application_id").references(() => application.id, { onDelete: "set null" }),
tags: pg.text("tags").array().notNull().default([]),
isLocked: pg.boolean("is_locked").notNull().default(false),
// In Trash since this moment; purged after 30 days.
trashedAt: pg.timestamp("trashed_at", { withTimezone: true }),
revision: pg.integer("revision").notNull().default(1),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
+9
View File
@@ -1,8 +1,10 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import * as pg from "drizzle-orm/pg-core";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string";
import { application } from "./applications";
import { user } from "./auth";
export const resume = pg.pgTable(
@@ -20,6 +22,13 @@ export const resume = pg.pgTable(
showDownloadButtons: pg.boolean("show_download_buttons").notNull().default(true),
isLocked: pg.boolean("is_locked").notNull().default(false),
password: pg.text("password"),
// "Made for this application": Check's job match, the assistant and Copy for a job use its posting.
// Separate from application.resume_id, the resume linked to (or sent with) an application.
applicationId: pg.text("application_id").references((): AnyPgColumn => application.id, { onDelete: "set null" }),
// In Trash since this moment; purged after 30 days. Trashed resumes are hidden and not shared.
trashedAt: pg.timestamp("trashed_at", { withTimezone: true }),
// A blank resume's name follows its headline until the user renames it.
autoName: pg.boolean("auto_name").notNull().default(false),
data: pg
.jsonb("data")
.notNull()
+3 -3
View File
@@ -329,10 +329,10 @@ export const TOOL_META = {
[T.deleteResume]: {
title: "Delete Resume",
description: [
"Permanently delete a resume and all its associated files (screenshots, PDFs), removing public access if published.",
"Move a resume to Trash, removing public access if published.",
"",
`This action is IRREVERSIBLE. Locked resumes cannot be deleted; use \`${T.unlockResume}\` first.`,
`Consider using \`${T.duplicateResume}\` to create a backup before deleting.`,
"It stays in Trash for 30 days, where the user can restore it from the app; then it and its files are deleted.",
`Locked resumes cannot be moved; use \`${T.unlockResume}\` first.`,
].join("\n"),
inputSchema: z.object({ id: resumeIdSchema }),
annotations: { ...WRITE_DESTRUCTIVE, openWorldHint: true },