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
@@ -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: {