Add application tracker REST and MCP parity

Add comprehensive Application Tracker REST and MCP coverage, document the MCP workflow, add Markdown/ActionLint checks, bump the release version, and fill all extracted translations.
This commit is contained in:
Amruth Pillai
2026-07-07 17:02:39 +02:00
committed by GitHub
parent dfc5559625
commit 46afc65cc6
80 changed files with 9938 additions and 809 deletions
+49
View File
@@ -34,3 +34,52 @@ describe("applicationDto jobDescription", () => {
).toThrow();
});
});
describe("applicationDto document uploads", () => {
it("accepts PDF application documents", () => {
const file = new File(["%PDF-1.4"], "resume.pdf", { type: "application/pdf" });
const parsed = applicationDto.attachDocument.input.parse({
id: "application-1",
kind: "resume",
file,
});
expect(parsed.kind).toBe("resume");
expect(parsed.file.name).toBe("resume.pdf");
});
it("rejects non-PDF application documents", () => {
const file = new File(["hello"], "cover.txt", { type: "text/plain" });
expect(() =>
applicationDto.attachDocument.input.parse({
id: "application-1",
kind: "cover-letter",
file,
}),
).toThrow();
});
it("rejects unknown application document kinds", () => {
const file = new File(["%PDF-1.4"], "resume.pdf", { type: "application/pdf" });
expect(() =>
applicationDto.attachDocument.input.parse({
id: "application-1",
kind: "portfolio",
file,
}),
).toThrow();
});
});
describe("applicationDto zero-argument inputs", () => {
it("normalizes stats input to an empty object", () => {
expect(applicationDto.stats.input.parse(undefined)).toEqual({});
});
it("normalizes tags input to an empty object", () => {
expect(applicationDto.tags.input.parse(undefined)).toEqual({});
});
});
+29 -2
View File
@@ -9,6 +9,14 @@ import {
} from "@reactive-resume/schema/applications/data";
const MAX_APPLICATION_JOB_DESCRIPTION_CHARS = 20_000;
const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024;
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
const applicationDocumentFileSchema = z
.file()
.max(MAX_APPLICATION_DOCUMENT_BYTES, "File size must be less than 10MB")
.mime(["application/pdf"], "Application documents must be PDF files.");
const httpUrlSchema = z
.string()
@@ -124,6 +132,23 @@ export const applicationDto = {
output: applicationSchema.omit({ userId: true }),
},
attachDocument: {
input: z.object({
id: z.string(),
kind: applicationDocumentKindSchema,
file: applicationDocumentFileSchema,
}),
output: applicationSchema.omit({ userId: true }),
},
removeDocument: {
input: z.object({
id: z.string(),
kind: applicationDocumentKindSchema,
}),
output: applicationSchema.omit({ userId: true }),
},
addNote: {
input: z.object({ id: z.string(), text: z.string().trim().min(1) }),
output: applicationSchema.omit({ userId: true }),
@@ -153,7 +178,7 @@ export const applicationDto = {
// Aggregates for the Insights view. Everything else (funnel, sankey, tiles) is derived
// client-side from these raw counts via computeInsights().
stats: {
input: z.void(),
input: z.object({}).optional().default({}),
output: z.object({
total: z.number(),
byStage: z.array(z.object({ status: applicationStatusSchema, count: z.number() })),
@@ -162,7 +187,9 @@ export const applicationDto = {
},
tags: {
input: z.void(),
input: z.object({}).optional().default({}),
output: z.array(z.string()),
},
};
export type ApplicationDocumentKind = z.infer<typeof applicationDocumentKindSchema>;
@@ -97,6 +97,66 @@ export const crudRouter = {
return applicationService.update({ userId: context.user.id, ...input });
}),
attachDocument: protectedProcedure
.route({
method: "POST",
path: "/applications/{id}/documents/{kind}",
tags: ["Applications"],
operationId: "attachApplicationDocument",
summary: "Attach an application document",
description:
"Uploads and attaches a PDF document to an application. Kind must be either resume or cover-letter. Requires authentication.",
successDescription: "The updated application.",
spec: (current) => {
const requestBody = current.requestBody;
if (!requestBody || "$ref" in requestBody) return current;
const multipart = requestBody.content?.["multipart/form-data"];
if (!multipart) return current;
return {
...current,
requestBody: {
...requestBody,
content: { "multipart/form-data": multipart },
},
};
},
})
.input(applicationDto.attachDocument.input)
.use(resumeMutationRateLimit)
.output(applicationDto.attachDocument.output)
.handler(async ({ input, context }) => {
const buffer = await input.file.arrayBuffer();
return applicationService.attachDocument({
id: input.id,
userId: context.user.id,
kind: input.kind,
fileName: input.file.name,
contentType: input.file.type,
data: new Uint8Array(buffer),
});
}),
removeDocument: protectedProcedure
.route({
method: "DELETE",
path: "/applications/{id}/documents/{kind}",
tags: ["Applications"],
operationId: "removeApplicationDocument",
summary: "Remove an application document",
description:
"Removes a resume or cover-letter PDF from an application and clears the stored document fields. Requires authentication.",
successDescription: "The updated application.",
})
.input(applicationDto.removeDocument.input)
.use(resumeMutationRateLimit)
.output(applicationDto.removeDocument.output)
.handler(async ({ input, context }) => {
return applicationService.removeDocument({ id: input.id, userId: context.user.id, kind: input.kind });
}),
addNote: protectedProcedure
.route({
method: "POST",
@@ -7,6 +7,8 @@ export const applicationsRouter = {
create: crudRouter.create,
import: crudRouter.import,
update: crudRouter.update,
attachDocument: crudRouter.attachDocument,
removeDocument: crudRouter.removeDocument,
addNote: crudRouter.addNote,
delete: crudRouter.delete,
bulkUpdate: crudRouter.bulkUpdate,
@@ -9,6 +9,7 @@ const dbMock = vi.hoisted(() => ({
}));
const resumeGetByIdMock = vi.hoisted(() => vi.fn());
const storageDeleteMock = vi.hoisted(() => vi.fn());
const uploadFileMock = vi.hoisted(() => vi.fn());
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
vi.mock("@reactive-resume/db/schema", () => ({
@@ -36,6 +37,7 @@ vi.mock("../resume/service", () => ({
}));
vi.mock("../storage/service", () => ({
getStorageService: () => ({ delete: storageDeleteMock }),
uploadFile: uploadFileMock,
}));
const { applicationService } = await import("./service");
@@ -51,17 +53,34 @@ const existing = {
coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf",
};
beforeEach(() => {
const createSelectChain = (rows: unknown[]) => ({
from: () => ({
where: () => Promise.resolve(rows),
}),
});
const setSelectResults = (...results: unknown[][]) => {
dbMock.select.mockReset();
for (const rows of results) {
dbMock.select.mockReturnValueOnce(createSelectChain(rows));
}
dbMock.select.mockReturnValue(createSelectChain([]));
};
beforeEach(() => {
dbMock.insert.mockReset();
dbMock.update.mockReset();
dbMock.delete.mockReset();
resumeGetByIdMock.mockReset();
storageDeleteMock.mockReset();
uploadFileMock.mockReset();
resumeGetByIdMock.mockResolvedValue({ id: "resume-1" });
storageDeleteMock.mockResolvedValue(true);
// requireOwned: db.select().from().where() resolves to [existing]
dbMock.select.mockReturnValue({ from: () => ({ where: () => Promise.resolve([{ ...existing }]) }) });
uploadFileMock.mockResolvedValue({
url: "/api/uploads/user-1/pictures/new.pdf",
key: "uploads/user-1/pictures/new.pdf",
});
setSelectResults([{ ...existing }]);
});
describe("applicationService.create", () => {
@@ -136,22 +155,129 @@ describe("applicationService.delete", () => {
});
});
describe("applicationService.attachDocument", () => {
it("uploads a PDF resume document and stores it on the application", async () => {
setSelectResults([{ ...existing }], [{ ...existing }], []);
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
await applicationService.attachDocument({
id: "app-1",
userId: "user-1",
kind: "resume",
fileName: "sent-resume.pdf",
contentType: "application/pdf",
data: new Uint8Array([1, 2, 3]),
});
expect(uploadFileMock).toHaveBeenCalledWith({
userId: "user-1",
contentType: "application/pdf",
data: new Uint8Array([1, 2, 3]),
});
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
resumeFileUrl: "/api/uploads/user-1/pictures/new.pdf",
resumeFileName: "sent-resume.pdf",
}),
);
});
it("rejects non-PDF documents before upload", async () => {
await expect(
applicationService.attachDocument({
id: "app-1",
userId: "user-1",
kind: "cover-letter",
fileName: "cover.txt",
contentType: "text/plain",
data: new Uint8Array([1]),
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(uploadFileMock).not.toHaveBeenCalled();
});
it("does not delete the replaced upload when another application still references it", async () => {
setSelectResults(
[{ ...existing }],
[{ ...existing }],
[
{
id: "app-2",
resumeFileUrl: existing.resumeFileUrl,
coverLetterUrl: null,
},
],
);
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
await applicationService.attachDocument({
id: "app-1",
userId: "user-1",
kind: "resume",
fileName: "sent-resume.pdf",
contentType: "application/pdf",
data: new Uint8Array([1, 2, 3]),
});
expect(storageDeleteMock).not.toHaveBeenCalledWith("uploads/user-1/pictures/resume.pdf");
});
});
describe("applicationService.removeDocument", () => {
it("clears and deletes an owned cover letter document", async () => {
setSelectResults([{ ...existing }], [{ ...existing }], []);
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
await applicationService.removeDocument({ id: "app-1", userId: "user-1", kind: "cover-letter" });
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
coverLetterUrl: null,
coverLetterName: null,
}),
);
expect(storageDeleteMock).toHaveBeenCalledWith("uploads/user-1/pictures/cover.pdf");
});
it("does not delete a removed upload while another application still references it", async () => {
setSelectResults(
[{ ...existing }],
[{ ...existing }],
[
{
id: "app-2",
resumeFileUrl: null,
coverLetterUrl: existing.coverLetterUrl,
},
],
);
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
await applicationService.removeDocument({ id: "app-1", userId: "user-1", kind: "cover-letter" });
expect(storageDeleteMock).not.toHaveBeenCalledWith("uploads/user-1/pictures/cover.pdf");
});
});
describe("applicationService.bulkDelete", () => {
it("deletes uploaded attachments for deleted owned applications", async () => {
dbMock.select.mockReturnValue({
from: () => ({
where: () =>
Promise.resolve([
{ ...existing, id: "app-1" },
{
...existing,
id: "app-2",
resumeFileUrl: "http://localhost:3000/api/uploads/user-2/pictures/ignored.pdf",
coverLetterUrl: null,
},
]),
}),
});
setSelectResults(
[
{ ...existing, id: "app-1" },
{
...existing,
id: "app-2",
resumeFileUrl: "http://localhost:3000/api/uploads/user-2/pictures/ignored.pdf",
coverLetterUrl: null,
},
],
[],
);
dbMock.delete.mockReturnValue({
where: () => ({ returning: () => Promise.resolve([{ id: "app-1" }, { id: "app-2" }]) }),
});
@@ -1,4 +1,5 @@
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type { ApplicationDocumentKind } from "../../dto/application";
import { ORPCError } from "@orpc/client";
import { and, arrayContains, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
@@ -6,7 +7,7 @@ import * as schema from "@reactive-resume/db/schema";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { generateId } from "@reactive-resume/utils/string";
import { resumeService } from "../resume/service";
import { getStorageService } from "../storage/service";
import { getStorageService, uploadFile } from "../storage/service";
const stageLabel = (status: ApplicationStatus) => STAGES.find((s) => s.value === status)?.label ?? status;
@@ -79,7 +80,7 @@ async function deleteApplicationAttachments(
userId: string,
applications: { resumeFileUrl?: string | null; coverLetterUrl?: string | null }[],
) {
const keys = [
const candidateKeys = [
...new Set(
applications.flatMap((application) => [
storageKeyFromApplicationUrl(userId, application.resumeFileUrl),
@@ -88,11 +89,41 @@ async function deleteApplicationAttachments(
),
].filter((key): key is string => !!key);
if (candidateKeys.length === 0) return;
const remainingApplications = await db
.select({
resumeFileUrl: schema.application.resumeFileUrl,
coverLetterUrl: schema.application.coverLetterUrl,
})
.from(schema.application)
.where(eq(schema.application.userId, userId));
const referencedKeys = new Set(
remainingApplications.flatMap((application) => [
storageKeyFromApplicationUrl(userId, application.resumeFileUrl),
storageKeyFromApplicationUrl(userId, application.coverLetterUrl),
]),
);
const keys = candidateKeys.filter((key) => !referencedKeys.has(key));
if (keys.length === 0) return;
const storageService = getStorageService();
await Promise.allSettled(keys.map((key) => storageService.delete(key)));
}
function documentFields(kind: ApplicationDocumentKind) {
return kind === "resume"
? ({
url: "resumeFileUrl",
name: "resumeFileName",
} as const)
: ({
url: "coverLetterUrl",
name: "coverLetterName",
} as const);
}
const stripUserId = <T extends { userId: string }>(row: T) => {
const { userId: _userId, ...rest } = row;
return rest;
@@ -197,6 +228,70 @@ export const applicationService = {
return stripUserId(updated);
},
attachDocument: async (input: {
id: string;
userId: string;
kind: ApplicationDocumentKind;
fileName: string;
data: Uint8Array;
contentType: string;
}) => {
if (input.contentType !== "application/pdf") {
throw new ORPCError("BAD_REQUEST", { message: "Application documents must be PDF files." });
}
const existing = await requireOwned(input.id, input.userId);
const fields = documentFields(input.kind);
const uploaded = await uploadFile({
userId: input.userId,
data: input.data,
contentType: input.contentType,
});
try {
const updated = await applicationService.update({
id: input.id,
userId: input.userId,
[fields.url]: uploaded.url,
[fields.name]: input.fileName,
});
await deleteApplicationAttachments(input.userId, [
{
resumeFileUrl: fields.url === "resumeFileUrl" ? existing.resumeFileUrl : null,
coverLetterUrl: fields.url === "coverLetterUrl" ? existing.coverLetterUrl : null,
},
]);
return updated;
} catch (error) {
await getStorageService()
.delete(uploaded.key)
.catch(() => false);
throw error;
}
},
removeDocument: async (input: { id: string; userId: string; kind: ApplicationDocumentKind }) => {
const existing = await requireOwned(input.id, input.userId);
const fields = documentFields(input.kind);
const updated = await applicationService.update({
id: input.id,
userId: input.userId,
[fields.url]: null,
[fields.name]: null,
});
await deleteApplicationAttachments(input.userId, [
{
resumeFileUrl: fields.url === "resumeFileUrl" ? existing.resumeFileUrl : null,
coverLetterUrl: fields.url === "coverLetterUrl" ? existing.coverLetterUrl : null,
},
]);
return updated;
},
// Persist AI-owned enrichment (match score + freeform metadata). Separate from the editable
// update path so these fields are only ever written by the AI procedures.
setAiResult: async (input: {