feat(applications): job application tracker with AI copilot

Add an Applications module at /dashboard/applications: pipeline board
(dnd-kit), table view with bulk actions, Insights (fit tiles, funnel,
sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and
Add/Edit/Detail slide-overs. Each application links a live Reactive
Resume.

AI "Application Copilot" (applications.ai.*): job-posting autofill,
resume↔job match score (fit ring), resume tailoring, and cover-letter /
follow-up drafting — via the user's configured provider.

Board cards + table rows get context menus (edit / move / archive /
delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox.

Also includes local TanStack devtools setup and toolchain bumps.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f
This commit is contained in:
Amruth Pillai
2026-07-05 13:42:14 +02:00
parent 6e7fc68068
commit 893df25aff
59 changed files with 26703 additions and 145 deletions
+1 -1
View File
@@ -51,7 +51,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
}
}
+147
View File
@@ -0,0 +1,147 @@
import { createSelectSchema } from "drizzle-zod";
import z from "zod";
import * as schema from "@reactive-resume/db/schema";
import {
activityEventSchema,
aiMetadataSchema,
applicationStatusSchema,
contactSchema,
} from "@reactive-resume/schema/applications/data";
const applicationSchema = createSelectSchema(schema.application, {
id: z.string().describe("The ID of the application."),
company: z.string().trim().min(1).describe("The company applied to."),
role: z.string().trim().min(1).describe("The role / job title."),
location: z.string().trim().nullable(),
salary: z.string().trim().nullable(),
status: applicationStatusSchema.describe("The current pipeline stage."),
archived: z.boolean(),
resumeId: z.string().nullable().describe("The linked Reactive Resume, if any."),
source: z.string().trim().nullable(),
sourceUrl: z.string().trim().nullable(),
jobDescription: z.string().nullable(),
matchScore: z.number().int().min(0).max(100).nullable(),
aiMetadata: aiMetadataSchema.nullable(),
campaign: z.string().trim().nullable(),
notes: z.string().nullable(),
followUpAt: z.date().nullable(),
followUpNote: z.string().trim().nullable(),
tags: z.array(z.string()),
contacts: z.array(contactSchema),
activity: z.array(activityEventSchema),
appliedAt: z.date(),
createdAt: z.date(),
updatedAt: z.date(),
});
// Fields a client is allowed to set/change. `status`, `activity` and AI-owned fields are
// excluded here — status changes go through the auto-logging update path, activity through
// addNote, and AI fields are written only by the (reserved) AI procedures.
const editableSchema = applicationSchema.pick({
company: true,
role: true,
location: true,
salary: true,
source: true,
sourceUrl: true,
jobDescription: true,
campaign: true,
notes: true,
followUpAt: true,
followUpNote: true,
contacts: true,
resumeId: true,
tags: true,
});
const createInputSchema = editableSchema.partial().extend({
company: applicationSchema.shape.company,
role: applicationSchema.shape.role,
status: applicationStatusSchema.optional(),
});
export const applicationDto = {
list: {
input: z
.object({
status: applicationStatusSchema.optional(),
campaign: z.string().optional(),
tags: z.array(z.string()).optional(),
includeArchived: z.boolean().optional().default(false),
})
.optional()
.default({ includeArchived: false }),
output: z.array(applicationSchema.omit({ userId: true })),
},
getById: {
input: applicationSchema.pick({ id: true }),
output: applicationSchema.omit({ userId: true }),
},
create: {
input: createInputSchema,
output: z.string().describe("The ID of the created application."),
},
// Bulk create from a CSV import. Each item is a create input; company/role required per item.
import: {
input: z.object({ items: z.array(createInputSchema).min(1).max(500) }),
output: z.object({ imported: z.number() }),
},
update: {
input: editableSchema
.partial()
.extend({ id: z.string(), status: applicationStatusSchema.optional(), archived: z.boolean().optional() }),
output: applicationSchema.omit({ userId: true }),
},
addNote: {
input: z.object({ id: z.string(), text: z.string().trim().min(1) }),
output: applicationSchema.omit({ userId: true }),
},
delete: {
input: applicationSchema.pick({ id: true }),
output: z.void(),
},
// Table bulk actions: move stage, archive/unarchive, add tags across a selection.
bulkUpdate: {
input: z.object({
ids: z.array(z.string()).min(1),
status: applicationStatusSchema.optional(),
archived: z.boolean().optional(),
addTags: z.array(z.string()).optional(),
}),
output: z.object({ updated: z.number() }),
},
bulkDelete: {
input: z.object({ ids: z.array(z.string()).min(1) }),
output: z.object({ deleted: z.number() }),
},
// Aggregates for the Insights view. Everything else (funnel, sankey, tiles) is derived
// client-side from these raw counts via computeInsights().
stats: {
input: z.object({ campaign: z.string().optional() }).optional(),
output: z.object({
total: z.number(),
byStage: z.array(z.object({ status: applicationStatusSchema, count: z.number() })),
bySource: z.array(z.object({ source: z.string(), count: z.number() })),
}),
},
// Distinct campaign names for the sidebar/filter (Phase 2 uses the same shape).
campaigns: {
input: z.void(),
output: z.array(z.object({ name: z.string(), count: z.number() })),
},
tags: {
input: z.void(),
output: z.array(z.string()),
},
};
@@ -0,0 +1,252 @@
import { ORPCError } from "@orpc/client";
import { generateText } from "ai";
import z from "zod";
import { generateId, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { getModel } from "../ai/service";
import { aiProvidersService } from "../ai-providers/service";
import { resumeService } from "../resume/service";
import { applicationService } from "./service";
const reserved = { tags: ["Applications", "AI"] } as const;
// Resolve the user's default (tested + enabled) AI provider into a ready model instance.
async function resolveModel(userId: string) {
const provider = await aiProvidersService.getDefaultRunnable({ userId });
if (!provider) {
throw new ORPCError("BAD_REQUEST", {
message: "No AI provider is configured. Add one in Settings → Integrations to use AI features.",
});
}
return getModel({
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
...(provider.baseURL ? { baseURL: provider.baseURL } : {}),
});
}
// generateText + tolerant JSON extraction + Zod validation. Mirrors the resume-analysis pattern
// (the SDK's generateObject isn't wired for every provider here, so we parse defensively).
async function generateJson<T>(model: Awaited<ReturnType<typeof resolveModel>>, prompt: string, schema: z.ZodType<T>) {
const { text } = await generateText({ model, messages: [{ role: "user", content: prompt }] });
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
const candidate = fenced?.[1] ?? text;
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start === -1 || end === -1 || end < start) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "The AI response could not be parsed." });
}
return schema.parse(JSON.parse(candidate.slice(start, end + 1)));
}
async function generatePlainText(model: Awaited<ReturnType<typeof resolveModel>>, prompt: string) {
const { text } = await generateText({ model, messages: [{ role: "user", content: prompt }] });
return text.trim();
}
// Best-effort fetch + strip of a job posting page. http(s) only, size/time capped.
async function fetchJobPostingText(url: string): Promise<string> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new ORPCError("BAD_REQUEST", { message: "The job posting URL is invalid." });
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new ORPCError("BAD_REQUEST", { message: "Only http(s) job posting URLs are supported." });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { "user-agent": "ReactiveResumeBot/1.0" },
});
if (!response.ok)
throw new ORPCError("BAD_REQUEST", { message: `Couldn't fetch the posting (HTTP ${response.status}).` });
const html = (await response.text()).slice(0, 200_000);
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 8_000);
} catch (error) {
if (error instanceof ORPCError) throw error;
throw new ORPCError("BAD_REQUEST", { message: "Couldn't read the job posting. Paste the description instead." });
} finally {
clearTimeout(timeout);
}
}
const autofillOutput = z.object({
company: z.string(),
role: z.string(),
location: z.string(),
salary: z.string(),
jobDescription: z.string(),
});
// Tolerant of LLM variance: clamp the score, cap the lists by slicing rather than rejecting.
const matchScoreOutput = z.object({
score: z.coerce
.number()
.catch(0)
.transform((n) => Math.max(0, Math.min(100, Math.round(n)))),
gaps: z
.array(z.string())
.catch([])
.transform((a) => a.slice(0, 8)),
strengths: z
.array(z.string())
.catch([])
.transform((a) => a.slice(0, 8)),
});
export const aiRouter = {
// Extract structured fields from a pasted job description or a posting URL.
autofill: protectedProcedure
.route({ method: "POST", path: "/applications/ai/autofill", operationId: "aiAutofillApplication", ...reserved })
.input(z.object({ sourceUrl: z.string().optional(), jobDescription: z.string().optional() }))
.use(aiRequestRateLimit)
.output(autofillOutput)
.handler(async ({ context, input }) => {
const model = await resolveModel(context.user.id);
const posting =
input.jobDescription?.trim() || (input.sourceUrl ? await fetchJobPostingText(input.sourceUrl) : "");
if (!posting) {
throw new ORPCError("BAD_REQUEST", { message: "Provide a job posting URL or paste the description." });
}
return generateJson(
model,
`Extract the following fields from this job posting. Return ONLY JSON with keys company, role, location, salary, jobDescription. Use an empty string for anything not stated. "jobDescription" should be a concise 12 paragraph plain-text summary of the responsibilities and requirements.\n\nJOB POSTING:\n${posting}`,
autofillOutput,
);
}),
// Score the linked resume against the application's job description.
matchScore: protectedProcedure
.route({
method: "POST",
path: "/applications/{id}/ai/match-score",
operationId: "aiApplicationMatchScore",
...reserved,
})
.input(z.object({ id: z.string() }))
.use(aiRequestRateLimit)
.output(matchScoreOutput)
.handler(async ({ context, input }) => {
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
if (!application.resumeId)
throw new ORPCError("BAD_REQUEST", { message: "Link a resume to this application first." });
if (!application.jobDescription) {
throw new ORPCError("BAD_REQUEST", { message: "Add a job description (via Auto-fill or Edit) first." });
}
const [model, resume] = await Promise.all([
resolveModel(context.user.id),
resumeService.getById({ id: application.resumeId, userId: context.user.id }),
]);
const result = await generateJson(
model,
`Compare this resume against the job description. Return ONLY JSON with keys score (integer 0-100 fit), gaps (array of short missing-qualification strings), strengths (array of short matching-strength strings).\n\nRESUME:\n${JSON.stringify(resume.data)}\n\nJOB DESCRIPTION:\n${application.jobDescription}`,
matchScoreOutput,
);
await applicationService.setAiResult({
id: input.id,
userId: context.user.id,
matchScore: result.score,
aiMetadata: { matchScore: result },
});
return result;
}),
// Generate a cover letter or recruiter follow-up from the application + resume context.
draftMessage: protectedProcedure
.route({
method: "POST",
path: "/applications/{id}/ai/draft-message",
operationId: "aiDraftApplicationMessage",
...reserved,
})
.input(z.object({ id: z.string(), kind: z.enum(["cover-letter", "follow-up"]) }))
.use(aiRequestRateLimit)
.output(z.object({ text: z.string() }))
.handler(async ({ context, input }) => {
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
const model = await resolveModel(context.user.id);
const resume = application.resumeId
? await resumeService.getById({ id: application.resumeId, userId: context.user.id }).catch(() => null)
: null;
const context_ = `ROLE: ${application.role} at ${application.company}${application.location ? ` (${application.location})` : ""}\n${application.jobDescription ? `JOB DESCRIPTION:\n${application.jobDescription}\n` : ""}${resume ? `CANDIDATE RESUME:\n${JSON.stringify(resume.data)}` : ""}`;
const prompt =
input.kind === "cover-letter"
? `Write a concise, specific cover letter (250-350 words, no placeholders like [Name]) for this application, drawing on the resume. Return only the letter text.\n\n${context_}`
: `Write a short, polite follow-up message (80-120 words) to a recruiter checking in on this application. Warm but not pushy. Return only the message text.\n\n${context_}`;
return { text: await generatePlainText(model, prompt) };
}),
// Create a tailored copy of the linked resume (job-specific summary) and link it to the application.
tailorResume: protectedProcedure
.route({
method: "POST",
path: "/applications/{id}/ai/tailor-resume",
operationId: "aiTailorResumeForApplication",
...reserved,
})
.input(z.object({ id: z.string() }))
.use(aiRequestRateLimit)
.output(z.object({ resumeId: z.string(), name: z.string() }))
.handler(async ({ context, input }) => {
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
if (!application.resumeId)
throw new ORPCError("BAD_REQUEST", { message: "Link a resume to this application first." });
if (!application.jobDescription) {
throw new ORPCError("BAD_REQUEST", { message: "Add a job description (via Auto-fill or Edit) first." });
}
const [model, resume] = await Promise.all([
resolveModel(context.user.id),
resumeService.getById({ id: application.resumeId, userId: context.user.id }),
]);
const { summary } = await generateJson(
model,
`Rewrite this candidate's professional summary to target the job below. Return ONLY JSON { "summary": "<one to two sentence HTML paragraph, e.g. <p>…</p>>" }. Keep it truthful to the resume.\n\nRESUME:\n${JSON.stringify(resume.data)}\n\nJOB:\n${application.role} at ${application.company}\n${application.jobDescription}`,
z.object({ summary: z.string() }),
);
const name = `Tailored — ${application.company} · ${application.role}`.slice(0, 60);
const tailoredData = { ...resume.data, summary: { ...resume.data.summary, content: summary } };
const newResumeId = await resumeService.create({
userId: context.user.id,
name,
slug: `${slugify(name)}-${generateId().slice(0, 6)}`,
tags: [...resume.tags, "tailored"],
data: tailoredData,
locale: context.locale,
});
// Point the application at the tailored copy and log it on the timeline.
await applicationService.update({ id: input.id, userId: context.user.id, resumeId: newResumeId });
await applicationService.addNote({
id: input.id,
userId: context.user.id,
text: `AI tailored a resume: ${name}`,
});
return { resumeId: newResumeId, name };
}),
};
@@ -0,0 +1,219 @@
import { protectedProcedure } from "../../context";
import { applicationDto } from "../../dto/application";
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
import { applicationService } from "./service";
export const crudRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/applications",
tags: ["Applications"],
operationId: "listApplications",
summary: "List job applications",
description:
"Returns all job applications belonging to the authenticated user, most recently updated first. Archived applications are excluded unless includeArchived is set. Optionally filter by pipeline stage. Requires authentication.",
successDescription: "A list of the user's job applications.",
})
.input(applicationDto.list.input)
.output(applicationDto.list.output)
.handler(async ({ input, context }) => {
return applicationService.list({
userId: context.user.id,
...(input.status ? { status: input.status } : {}),
...(input.campaign ? { campaign: input.campaign } : {}),
...(input.tags ? { tags: input.tags } : {}),
includeArchived: input.includeArchived,
});
}),
getById: protectedProcedure
.route({
method: "GET",
path: "/applications/{id}",
tags: ["Applications"],
operationId: "getApplication",
summary: "Get application by ID",
description:
"Returns a single job application with its full detail (contacts, activity timeline, linked resume). Only applications belonging to the authenticated user can be retrieved. Requires authentication.",
successDescription: "The job application.",
})
.input(applicationDto.getById.input)
.output(applicationDto.getById.output)
.handler(async ({ input, context }) => {
return applicationService.getById({ id: input.id, userId: context.user.id });
}),
create: protectedProcedure
.route({
method: "POST",
path: "/applications",
tags: ["Applications"],
operationId: "createApplication",
summary: "Create a job application",
description:
"Creates a new job application in the pipeline. Company and role are required; all other fields (stage, location, salary, source, linked resume, follow-up, notes, contacts) are optional. Requires authentication.",
successDescription: "The ID of the newly created application.",
})
.input(applicationDto.create.input)
.use(resumeMutationRateLimit)
.output(applicationDto.create.output)
.handler(async ({ input, context }) => {
return applicationService.create({ userId: context.user.id, ...input });
}),
import: protectedProcedure
.route({
method: "POST",
path: "/applications/import",
tags: ["Applications"],
operationId: "importApplications",
summary: "Bulk import applications",
description:
"Creates many applications at once from a parsed CSV. Each item requires company and role. Returns the number imported. Requires authentication.",
successDescription: "The number of applications imported.",
})
.input(applicationDto.import.input)
.use(resumeMutationRateLimit)
.output(applicationDto.import.output)
.handler(async ({ input, context }) => {
return applicationService.importMany({ userId: context.user.id, items: input.items });
}),
update: protectedProcedure
.route({
method: "PUT",
path: "/applications/{id}",
tags: ["Applications"],
operationId: "updateApplication",
summary: "Update a job application",
description:
"Updates one or more fields of an application, including moving it to a different pipeline stage or archiving it. Moving stages automatically appends an entry to the activity timeline. Only provided fields are changed. Requires authentication.",
successDescription: "The updated application.",
})
.input(applicationDto.update.input)
.use(resumeMutationRateLimit)
.output(applicationDto.update.output)
.handler(async ({ input, context }) => {
return applicationService.update({ userId: context.user.id, ...input });
}),
addNote: protectedProcedure
.route({
method: "POST",
path: "/applications/{id}/notes",
tags: ["Applications"],
operationId: "addApplicationNote",
summary: "Log a note on the timeline",
description: "Appends a free-text note to the application's activity timeline. Requires authentication.",
successDescription: "The updated application.",
})
.input(applicationDto.addNote.input)
.use(resumeMutationRateLimit)
.output(applicationDto.addNote.output)
.handler(async ({ input, context }) => {
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text });
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/applications/{id}",
tags: ["Applications"],
operationId: "deleteApplication",
summary: "Delete a job application",
description: "Permanently deletes a job application. Requires authentication.",
successDescription: "The application was deleted successfully.",
})
.input(applicationDto.delete.input)
.use(resumeMutationRateLimit)
.output(applicationDto.delete.output)
.handler(async ({ input, context }) => {
return applicationService.delete({ id: input.id, userId: context.user.id });
}),
bulkUpdate: protectedProcedure
.route({
method: "POST",
path: "/applications/bulk-update",
tags: ["Applications"],
operationId: "bulkUpdateApplications",
summary: "Bulk update applications",
description:
"Applies the same change (move stage, archive/unarchive, add tags) to multiple applications at once. Requires authentication.",
successDescription: "The number of applications updated.",
})
.input(applicationDto.bulkUpdate.input)
.use(resumeMutationRateLimit)
.output(applicationDto.bulkUpdate.output)
.handler(async ({ input, context }) => {
return applicationService.bulkUpdate({ userId: context.user.id, ...input });
}),
bulkDelete: protectedProcedure
.route({
method: "POST",
path: "/applications/bulk-delete",
tags: ["Applications"],
operationId: "bulkDeleteApplications",
summary: "Bulk delete applications",
description: "Permanently deletes multiple applications at once. Requires authentication.",
successDescription: "The number of applications deleted.",
})
.input(applicationDto.bulkDelete.input)
.use(resumeMutationRateLimit)
.output(applicationDto.bulkDelete.output)
.handler(async ({ input, context }) => {
return applicationService.bulkDelete({ userId: context.user.id, ids: input.ids });
}),
stats: protectedProcedure
.route({
method: "GET",
path: "/applications/stats",
tags: ["Applications"],
operationId: "getApplicationStats",
summary: "Application pipeline stats",
description:
"Returns aggregate counts (per stage, per source) for the Insights view. Optionally scoped to a campaign. Requires authentication.",
successDescription: "Aggregate application counts.",
})
.input(applicationDto.stats.input)
.output(applicationDto.stats.output)
.handler(async ({ input, context }) => {
return applicationService.stats({
userId: context.user.id,
...(input?.campaign ? { campaign: input.campaign } : {}),
});
}),
campaigns: protectedProcedure
.route({
method: "GET",
path: "/applications/campaigns",
tags: ["Applications"],
operationId: "listApplicationCampaigns",
summary: "List campaigns",
description: "Returns distinct campaign names with their application counts. Requires authentication.",
successDescription: "Distinct campaigns with counts.",
})
.output(applicationDto.campaigns.output)
.handler(async ({ context }) => {
return applicationService.campaigns({ userId: context.user.id });
}),
tags: protectedProcedure
.route({
method: "GET",
path: "/applications/tags",
tags: ["Applications"],
operationId: "listApplicationTags",
summary: "List application tags",
description: "Returns the distinct tags used across the user's applications. Requires authentication.",
successDescription: "Distinct tags.",
})
.output(applicationDto.tags.output)
.handler(async ({ context }) => {
return applicationService.listTags({ userId: context.user.id });
}),
};
@@ -0,0 +1,18 @@
import { aiRouter } from "./ai";
import { crudRouter } from "./crud";
export const applicationsRouter = {
list: crudRouter.list,
getById: crudRouter.getById,
create: crudRouter.create,
import: crudRouter.import,
update: crudRouter.update,
addNote: crudRouter.addNote,
delete: crudRouter.delete,
bulkUpdate: crudRouter.bulkUpdate,
bulkDelete: crudRouter.bulkDelete,
stats: crudRouter.stats,
campaigns: crudRouter.campaigns,
tags: crudRouter.tags,
ai: aiRouter,
};
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the DB layer; the logic under test is the activity-timeline bookkeeping, not SQL.
const dbMock = vi.hoisted(() => ({
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
}));
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
vi.mock("@reactive-resume/db/schema", () => ({
application: { id: "id", userId: "user_id", status: "status", updatedAt: "updated_at" },
}));
vi.mock("drizzle-orm", () => ({ and: (...a: unknown[]) => a, desc: (x: unknown) => x, eq: (...a: unknown[]) => a }));
const { applicationService } = await import("./service");
const existing = {
id: "app-1",
userId: "user-1",
company: "Stripe",
role: "Engineer",
status: "saved" as const,
activity: [{ id: "e0", type: "created" as const, text: "Added to Saved", at: new Date() }],
};
beforeEach(() => {
dbMock.select.mockReset();
dbMock.insert.mockReset();
dbMock.update.mockReset();
// requireOwned: db.select().from().where() resolves to [existing]
dbMock.select.mockReturnValue({ from: () => ({ where: () => Promise.resolve([{ ...existing }]) }) });
});
describe("applicationService.create", () => {
it("seeds a 'created' activity event", async () => {
const values = vi.fn(() => Promise.resolve());
dbMock.insert.mockReturnValue({ values });
await applicationService.create({ userId: "user-1", company: "Stripe", role: "Engineer", status: "applied" });
const [[inserted]] = values.mock.calls as unknown as [[{ activity: { type: string }[] }]];
expect(inserted.activity).toHaveLength(1);
expect(inserted.activity.at(0)?.type).toBe("created");
});
});
describe("applicationService.update", () => {
const captureSet = () => {
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
return set;
};
it("appends a 'stage' event when the status changes", async () => {
const set = captureSet();
await applicationService.update({ id: "app-1", userId: "user-1", status: "applied" });
const [[arg]] = set.mock.calls as unknown as [[{ activity: { type: string }[] }]];
expect(arg.activity).toHaveLength(2);
expect(arg.activity.at(-1)?.type).toBe("stage");
});
it("does not append an event when the status is unchanged", async () => {
const set = captureSet();
await applicationService.update({ id: "app-1", userId: "user-1", notes: "hello" });
const [[arg]] = set.mock.calls as unknown as [[{ activity: unknown[] }]];
expect(arg.activity).toHaveLength(1);
});
});
@@ -0,0 +1,290 @@
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import { ORPCError } from "@orpc/client";
import { and, arrayContains, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { generateId } from "@reactive-resume/utils/string";
const stageLabel = (status: ApplicationStatus) => STAGES.find((s) => s.value === status)?.label ?? status;
function activityEvent(type: ActivityEvent["type"], text: string): ActivityEvent {
return { id: generateId(), type, text, at: new Date() };
}
// Editable fields shared by create/update. Kept explicit so Drizzle's typed insert/update
// checks catch mistakes; `status`/`activity` are handled separately (auto-logging).
// `| undefined` is explicit throughout because the DTO layer (zod `.partial()`) produces
// `T | undefined` and the repo compiles with exactOptionalPropertyTypes.
type EditableFields = {
company?: string | undefined;
role?: string | undefined;
location?: string | null | undefined;
salary?: string | null | undefined;
source?: string | null | undefined;
sourceUrl?: string | null | undefined;
jobDescription?: string | null | undefined;
campaign?: string | null | undefined;
notes?: string | null | undefined;
followUpAt?: Date | null | undefined;
followUpNote?: string | null | undefined;
contacts?: Contact[] | undefined;
resumeId?: string | null | undefined;
tags?: string[] | undefined;
};
// All reads/writes filter on userId — the single ownership guard every route funnels through.
async function requireOwned(id: string, userId: string) {
const [row] = await db
.select()
.from(schema.application)
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)));
if (!row) throw new ORPCError("NOT_FOUND");
return row;
}
const stripUserId = <T extends { userId: string }>(row: T) => {
const { userId: _userId, ...rest } = row;
return rest;
};
export const applicationService = {
list: async (input: {
userId: string;
status?: ApplicationStatus;
campaign?: string;
tags?: string[];
includeArchived?: boolean;
}) => {
const rows = await db
.select()
.from(schema.application)
.where(
and(
eq(schema.application.userId, input.userId),
input.status ? eq(schema.application.status, input.status) : undefined,
input.campaign ? eq(schema.application.campaign, input.campaign) : undefined,
input.tags && input.tags.length > 0 ? arrayContains(schema.application.tags, input.tags) : undefined,
),
)
.orderBy(desc(schema.application.updatedAt));
return rows.filter((row) => input.includeArchived || !row.archived).map(stripUserId);
},
getById: async (input: { id: string; userId: string }) => {
return stripUserId(await requireOwned(input.id, input.userId));
},
create: async (
input: EditableFields & { userId: string; company: string; role: string; status?: ApplicationStatus | undefined },
) => {
const { userId, status, ...fields } = input;
const id = generateId();
await db.insert(schema.application).values({
id,
userId,
status: status ?? "saved",
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
...fields,
});
return id;
},
importMany: async (input: {
userId: string;
items: (EditableFields & { company: string; role: string; status?: ApplicationStatus | undefined })[];
}) => {
if (input.items.length === 0) return { imported: 0 };
const values = input.items.map(({ status, ...fields }) => ({
id: generateId(),
userId: input.userId,
status: status ?? ("saved" as ApplicationStatus),
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
...fields,
}));
const rows = await db.insert(schema.application).values(values).returning({ id: schema.application.id });
return { imported: rows.length };
},
update: async (
input: EditableFields & {
id: string;
userId: string;
status?: ApplicationStatus | undefined;
archived?: boolean | undefined;
},
) => {
const existing = await requireOwned(input.id, input.userId);
const { id, userId, status, archived, ...fields } = input;
// Auto-log a timeline event when the stage actually changes.
const activity =
status && status !== existing.status
? [...existing.activity, activityEvent("stage", `Moved to ${stageLabel(status)}`)]
: existing.activity;
const [updated] = await db
.update(schema.application)
.set({
...fields,
...(status !== undefined ? { status } : {}),
...(archived !== undefined ? { archived } : {}),
activity,
})
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return stripUserId(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: {
id: string;
userId: string;
matchScore?: number | null;
aiMetadata?: AiMetadata | null;
}) => {
const [updated] = await db
.update(schema.application)
.set({
...(input.matchScore !== undefined ? { matchScore: input.matchScore } : {}),
...(input.aiMetadata !== undefined ? { aiMetadata: input.aiMetadata } : {}),
})
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return stripUserId(updated);
},
addNote: async (input: { id: string; userId: string; text: string }) => {
// Append in a single statement (activity || [event]) so concurrent notes can't drop each
// other via read-then-write; ownership is enforced by the WHERE clause.
const event = activityEvent("note", input.text);
const [updated] = await db
.update(schema.application)
.set({ activity: sql`${schema.application.activity} || ${JSON.stringify([event])}::jsonb` })
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return stripUserId(updated);
},
delete: async (input: { id: string; userId: string }) => {
const result = await db
.delete(schema.application)
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.returning({ id: schema.application.id });
if (result.length === 0) throw new ORPCError("NOT_FOUND");
},
bulkUpdate: async (input: {
userId: string;
ids: string[];
status?: ApplicationStatus | undefined;
archived?: boolean | undefined;
addTags?: string[] | undefined;
}) => {
const scope = and(inArray(schema.application.id, input.ids), eq(schema.application.userId, input.userId));
// Tags: union the new tags into the existing array (de-duplicated) in a single statement.
// Build an explicit `array[$1, $2]` — drizzle renders a bare JS array as a tuple `($1,$2)`,
// which can't be cast to text[].
const tagsExpr =
input.addTags && input.addTags.length > 0
? sql`(select array(select distinct unnest(${schema.application.tags} || array[${sql.join(
input.addTags.map((tag) => sql`${tag}`),
sql`, `,
)}]::text[])))`
: undefined;
// Stage moves must log a timeline event on every row that actually changed — mirror the
// single-item update path. Append the event only where the current status differs.
const activityExpr =
input.status !== undefined
? sql`case when ${schema.application.status} <> ${input.status}
then ${schema.application.activity} || ${JSON.stringify([activityEvent("stage", `Moved to ${stageLabel(input.status)}`)])}::jsonb
else ${schema.application.activity} end`
: undefined;
const rows = await db
.update(schema.application)
.set({
...(input.status !== undefined ? { status: input.status } : {}),
...(activityExpr ? { activity: activityExpr } : {}),
...(input.archived !== undefined ? { archived: input.archived } : {}),
...(tagsExpr ? { tags: tagsExpr } : {}),
})
.where(scope)
.returning({ id: schema.application.id });
return { updated: rows.length };
},
bulkDelete: async (input: { userId: string; ids: string[] }) => {
const rows = await db
.delete(schema.application)
.where(and(inArray(schema.application.id, input.ids), eq(schema.application.userId, input.userId)))
.returning({ id: schema.application.id });
return { deleted: rows.length };
},
// Raw counts for Insights; funnel/sankey/tiles are derived client-side from these.
stats: async (input: { userId: string; campaign?: string }) => {
const scope = and(
eq(schema.application.userId, input.userId),
eq(schema.application.archived, false),
input.campaign ? eq(schema.application.campaign, input.campaign) : undefined,
);
const byStage = await db
.select({ status: schema.application.status, count: sql<number>`count(*)::int` })
.from(schema.application)
.where(scope)
.groupBy(schema.application.status);
const bySource = await db
.select({ source: schema.application.source, count: sql<number>`count(*)::int` })
.from(schema.application)
.where(scope)
.groupBy(schema.application.source);
const total = byStage.reduce((sum, row) => sum + row.count, 0);
return {
total,
byStage,
bySource: bySource
.filter((row): row is { source: string; count: number } => !!row.source)
.sort((a, b) => b.count - a.count),
};
},
campaigns: async (input: { userId: string }) => {
const rows = await db
.select({ name: schema.application.campaign, count: sql<number>`count(*)::int` })
.from(schema.application)
.where(and(eq(schema.application.userId, input.userId), eq(schema.application.archived, false)))
.groupBy(schema.application.campaign);
return rows.filter((row): row is { name: string; count: number } => !!row.name).sort((a, b) => b.count - a.count);
},
listTags: async (input: { userId: string }) => {
const rows = await db
.select({ tag: sql<string>`distinct unnest(${schema.application.tags})` })
.from(schema.application)
.where(eq(schema.application.userId, input.userId));
return rows.map((row) => row.tag).sort((a, b) => a.localeCompare(b));
},
};
+2
View File
@@ -1,6 +1,7 @@
import { agentRouter } from "../features/agent/router";
import { aiRouter } from "../features/ai/router";
import { aiProvidersRouter } from "../features/ai-providers/router";
import { applicationsRouter } from "../features/applications/router";
import { authRouter } from "../features/auth/router";
import { flagsRouter } from "../features/flags/router";
import { resumeRouter } from "../features/resume/router";
@@ -11,6 +12,7 @@ export default {
ai: aiRouter,
aiProviders: aiProvidersRouter,
agent: agentRouter,
applications: applicationsRouter,
auth: authRouter,
flags: flagsRouter,
resume: resumeRouter,