mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 17:03:55 +10:00
feat: add application timeline history (#3237)
* feat: add application timeline history * fix: address application timeline review * fix: keep application tracker e2e stable * fix: use stable timeline e2e selector * fix: target timeline note input in e2e
This commit is contained in:
@@ -21,6 +21,8 @@ export const MCP_TOOL_NAME = {
|
||||
createApplication: "create_application",
|
||||
updateApplication: "update_application",
|
||||
addApplicationNote: "add_application_note",
|
||||
updateApplicationTimelineEntry: "update_application_timeline_entry",
|
||||
deleteApplicationTimelineEntry: "delete_application_timeline_entry",
|
||||
deleteApplication: "delete_application",
|
||||
bulkUpdateApplications: "bulk_update_applications",
|
||||
bulkDeleteApplications: "bulk_delete_applications",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> =
|
||||
[MCP_TOOL_NAME.createApplication]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.updateApplication]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.addApplicationNote]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.updateApplicationTimelineEntry]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.deleteApplicationTimelineEntry]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.deleteApplication]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.bulkUpdateApplications]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.bulkDeleteApplications]: WRITE_DESTRUCTIVE,
|
||||
|
||||
@@ -16,7 +16,9 @@ const applicationIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(`Application ID. Use \`${T.listApplications}\` to find valid IDs.`);
|
||||
const applicationTimelineEntryIdSchema = z.string().min(1).describe("Timeline entry ID from an application response.");
|
||||
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
|
||||
const timelineDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must use YYYY-MM-DD format.");
|
||||
const httpUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -67,6 +69,7 @@ const createApplicationSchema = z
|
||||
...applicationMutableFieldsSchema,
|
||||
company: z.string().min(1).describe("Company name."),
|
||||
role: z.string().min(1).describe("Role or job title."),
|
||||
stageEnteredAt: timelineDateSchema.optional().describe("Initial stage date in YYYY-MM-DD format."),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -343,9 +346,32 @@ export const TOOL_META = {
|
||||
[T.addApplicationNote]: {
|
||||
title: "Add Application Note",
|
||||
description: "Append a free-text note to an application's timeline.",
|
||||
inputSchema: z.object({ id: applicationIdSchema, text: z.string().min(1) }),
|
||||
inputSchema: z.object({
|
||||
id: applicationIdSchema,
|
||||
text: z.string().min(1),
|
||||
date: timelineDateSchema.optional().describe("Optional note date in YYYY-MM-DD format."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.addApplicationNote],
|
||||
},
|
||||
[T.updateApplicationTimelineEntry]: {
|
||||
title: "Update Application Timeline Entry",
|
||||
description: "Update a timeline entry date, or note text for note entries.",
|
||||
inputSchema: z
|
||||
.object({
|
||||
id: applicationIdSchema,
|
||||
entryId: applicationTimelineEntryIdSchema,
|
||||
date: timelineDateSchema.optional().describe("Replacement row date in YYYY-MM-DD format."),
|
||||
text: z.string().min(1).optional().describe("Replacement note text. Only note entries can change text."),
|
||||
})
|
||||
.refine((value) => value.date !== undefined || value.text !== undefined, "Provide date or text to update."),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateApplicationTimelineEntry],
|
||||
},
|
||||
[T.deleteApplicationTimelineEntry]: {
|
||||
title: "Delete Application Timeline Entry",
|
||||
description: "Delete a note or older stage entry. The current stage entry cannot be deleted.",
|
||||
inputSchema: z.object({ id: applicationIdSchema, entryId: applicationTimelineEntryIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.deleteApplicationTimelineEntry],
|
||||
},
|
||||
[T.deleteApplication]: {
|
||||
title: "Delete Application",
|
||||
description: "Permanently delete one job application and its owned uploaded documents.",
|
||||
|
||||
@@ -71,6 +71,8 @@ const clientMock = {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addNote: vi.fn(),
|
||||
updateTimelineEntry: vi.fn(),
|
||||
deleteTimelineEntry: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
bulkUpdate: vi.fn(),
|
||||
bulkDelete: vi.fn(),
|
||||
@@ -185,6 +187,51 @@ describe("registerTools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds dated application notes through the router client", async () => {
|
||||
clientMock.applications.addNote.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerTools(server as never, clientMock as never, new Headers());
|
||||
|
||||
const tool = registered.find((item) => item.name === "add_application_note")!;
|
||||
await tool.handler({ id: "app-1", text: "Recruiter replied", date: "2026-07-12" });
|
||||
|
||||
expect(clientMock.applications.addNote).toHaveBeenCalledWith({
|
||||
id: "app-1",
|
||||
text: "Recruiter replied",
|
||||
date: "2026-07-12",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates application timeline entries through the router client", async () => {
|
||||
clientMock.applications.updateTimelineEntry.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerTools(server as never, clientMock as never, new Headers());
|
||||
|
||||
const tool = registered.find((item) => item.name === "update_application_timeline_entry")!;
|
||||
await tool.handler({ id: "app-1", entryId: "entry-1", date: "2026-07-13", text: "Updated note" });
|
||||
|
||||
expect(clientMock.applications.updateTimelineEntry).toHaveBeenCalledWith({
|
||||
id: "app-1",
|
||||
entryId: "entry-1",
|
||||
date: "2026-07-13",
|
||||
text: "Updated note",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes application timeline entries through the router client", async () => {
|
||||
clientMock.applications.deleteTimelineEntry.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerTools(server as never, clientMock as never, new Headers());
|
||||
|
||||
const tool = registered.find((item) => item.name === "delete_application_timeline_entry")!;
|
||||
await tool.handler({ id: "app-1", entryId: "entry-1" });
|
||||
|
||||
expect(clientMock.applications.deleteTimelineEntry).toHaveBeenCalledWith({
|
||||
id: "app-1",
|
||||
entryId: "entry-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("imports applications with followUpAt coerced to Date and null preserved", async () => {
|
||||
clientMock.applications.import.mockResolvedValueOnce({ imported: 2 });
|
||||
const { server, registered } = makeFakeServer();
|
||||
|
||||
@@ -410,11 +410,33 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
server.registerTool(
|
||||
T.addApplicationNote,
|
||||
TOOL_META[T.addApplicationNote],
|
||||
withErrorHandling("adding application note", async ({ id, text: noteText }: { id: string; text: string }) => {
|
||||
return json(await client.applications.addNote({ id, text: noteText }));
|
||||
withErrorHandling(
|
||||
"adding application note",
|
||||
async ({ id, text: noteText, date }: { id: string; text: string; date?: string | undefined }) => {
|
||||
return json(await client.applications.addNote({ id, text: noteText, date }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
T.updateApplicationTimelineEntry,
|
||||
TOOL_META[T.updateApplicationTimelineEntry],
|
||||
withErrorHandling("updating application timeline entry", async (params) => {
|
||||
return json(await client.applications.updateTimelineEntry(params as never));
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
T.deleteApplicationTimelineEntry,
|
||||
TOOL_META[T.deleteApplicationTimelineEntry],
|
||||
withErrorHandling(
|
||||
"deleting application timeline entry",
|
||||
async ({ id, entryId }: { id: string; entryId: string }) => {
|
||||
return json(await client.applications.deleteTimelineEntry({ id, entryId }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
T.deleteApplication,
|
||||
TOOL_META[T.deleteApplication],
|
||||
|
||||
Reference in New Issue
Block a user