mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 14:01:42 +10:00
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:
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user