mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 23:07:01 +10:00
Add application tracker (#3220)
* 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 * feat(applications): close follow-up gaps + squash migrations Finish the deferred/open items on the applications tracker: - Cover-letter upload re-enabled. Fix the storage blocker by deriving the key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE) instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add coverLetterUrl/coverLetterName columns + Documents-section upload/remove. - Contacts editor in the detail sheet (add/edit/remove, keyed per app). - Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more"). - Extract new Lingui messages across locales. - Guard coverLetterUrl to http(s)/relative at the API boundary. Squash the five branch-only application-table migrations (create -> +tags -> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via drizzle-kit generate. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * chore: update dependencies * fix(web): address React Doctor findings — compiler, purity, query, component structure prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces, handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to module scope so they aren't rebuilt on every render. react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the non-assignment form to unblock auto-memoization. set-state-in-effect: derive updatedAtLabel at render time instead of syncing it through useState + useEffect. query-destructure-result: destructure useQuery results at call site in resume-analysis and resume-thumbnail to follow TanStack Query v5 convention. only-export-components: extract non-component exports to sibling .ts files so Fast Refresh can preserve component state: - getNextWeights → typography/get-next-weights.ts - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts - getLocaleOptions → features/locale/locale-options.tsx - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts - computeDelta + getSparklinePoints → statistics.utils.ts no-multi-comp: split multi-component files into focused companions: - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts fix(tests): add Resume type import to section-builder mocks and cast partial mock data as unknown as Resume to satisfy stricter type checking; fix noExplicitAny Biome errors in the same mocks. * feat(applications): improve performance * chore: fix knip issues * perf(builder): halve per-keystroke render cost Section-form fields called `form.handleSubmit()` on every keystroke, which re-validated the whole form and toggled submit state — firing the render cascade twice per character (~6809 renders/keystroke, FPS dropping to 9). Persist via a form-level `listeners.onChange` instead and drop the per-field `handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume subscriptions to metadata slices so they no longer re-render on content edits. Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview, and design controls verified working; 449/449 web tests pass. * perf(home): eliminate hero CLS from unreserved video box The hero <section> is `flex items-center` (shrink-to-fit), so the video wrapper's width depended on the video's intrinsic size, which only resolves after the media loads. aspect-ratio couldn't reserve height without a definite width, so the video grew from ~190px to ~563px after first paint and shoved the centered hero text down ~373px (CLS ~0.095). Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an explicit aspect ratio + width/height on the video so its box is reserved before load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl. * docs: add application tracker guides * chore(db): squash application migrations * fix(email): import React in auth template for server-side rendering compatibility * chore(release): v5.2.1 * Refactor resume rendering and builder workflows * fix: address application tracker review findings
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applicationDto } from "./application";
|
||||
|
||||
describe("applicationDto sourceUrl", () => {
|
||||
it("accepts http(s) URLs", () => {
|
||||
expect(
|
||||
applicationDto.create.input.parse({
|
||||
company: "Stripe",
|
||||
role: "Engineer",
|
||||
sourceUrl: "https://example.com/job",
|
||||
}).sourceUrl,
|
||||
).toBe("https://example.com/job");
|
||||
});
|
||||
|
||||
it("rejects URLs that would be unsafe in anchors", () => {
|
||||
expect(() =>
|
||||
applicationDto.create.input.parse({
|
||||
company: "Stripe",
|
||||
role: "Engineer",
|
||||
sourceUrl: "javascript:alert(1)",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applicationDto jobDescription", () => {
|
||||
it("rejects oversized descriptions before AI actions can use them", () => {
|
||||
expect(() =>
|
||||
applicationDto.create.input.parse({
|
||||
company: "Stripe",
|
||||
role: "Engineer",
|
||||
jobDescription: "x".repeat(20_001),
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
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 MAX_APPLICATION_JOB_DESCRIPTION_CHARS = 20_000;
|
||||
|
||||
const httpUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "URL must use http or https.");
|
||||
|
||||
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: httpUrlSchema.nullable(),
|
||||
jobDescription: z.string().max(MAX_APPLICATION_JOB_DESCRIPTION_CHARS).nullable(),
|
||||
matchScore: z.number().int().min(0).max(100).nullable(),
|
||||
aiMetadata: aiMetadataSchema.nullable(),
|
||||
notes: z.string().nullable(),
|
||||
// Rendered as an <a href>; only same-origin storage URLs are ever stored. Constrain to
|
||||
// http(s)/relative so a hand-crafted `update` can't smuggle a `javascript:` href.
|
||||
resumeFileUrl: z
|
||||
.string()
|
||||
.refine((value) => /^(https?:\/\/|\/)/.test(value), "Resume file URL must be http(s) or a relative path.")
|
||||
.nullable(),
|
||||
resumeFileName: z.string().nullable(),
|
||||
coverLetterUrl: z
|
||||
.string()
|
||||
.refine((value) => /^(https?:\/\/|\/)/.test(value), "Cover letter URL must be http(s) or a relative path.")
|
||||
.nullable(),
|
||||
coverLetterName: 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,
|
||||
notes: true,
|
||||
resumeFileUrl: true,
|
||||
resumeFileName: true,
|
||||
coverLetterUrl: true,
|
||||
coverLetterName: 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(),
|
||||
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.void(),
|
||||
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() })),
|
||||
}),
|
||||
},
|
||||
|
||||
tags: {
|
||||
input: z.void(),
|
||||
output: z.array(z.string()),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const lookupMock = vi.hoisted(() => vi.fn());
|
||||
const requestMock = vi.hoisted(() => vi.fn());
|
||||
const protectedProcedureMock = vi.hoisted(() => {
|
||||
const chain = {
|
||||
route: vi.fn(() => chain),
|
||||
input: vi.fn(() => chain),
|
||||
use: vi.fn(() => chain),
|
||||
output: vi.fn(() => chain),
|
||||
handler: vi.fn(() => chain),
|
||||
};
|
||||
return chain;
|
||||
});
|
||||
|
||||
vi.mock("node:dns/promises", () => ({ lookup: lookupMock }));
|
||||
vi.mock("node:http", () => ({ request: requestMock }));
|
||||
vi.mock("node:https", () => ({ request: requestMock }));
|
||||
vi.mock("ai", () => ({ generateText: vi.fn() }));
|
||||
vi.mock("../../context", () => ({ protectedProcedure: protectedProcedureMock }));
|
||||
vi.mock("../../middleware/rate-limit", () => ({ aiRequestRateLimit: vi.fn() }));
|
||||
vi.mock("../ai/service", () => ({ getModel: vi.fn() }));
|
||||
vi.mock("../ai-providers/service", () => ({ aiProvidersService: { getDefaultRunnable: vi.fn() } }));
|
||||
vi.mock("../resume/service", () => ({ resumeService: { getById: vi.fn(), create: vi.fn() } }));
|
||||
vi.mock("./service", () => ({
|
||||
applicationService: { getById: vi.fn(), setAiResult: vi.fn(), update: vi.fn(), addNote: vi.fn() },
|
||||
}));
|
||||
|
||||
const { autofillInputSchema, fetchJobPostingText } = await import("./ai");
|
||||
|
||||
function mockRequestResponse(statusCode: number, headers: Record<string, string>, body = "") {
|
||||
requestMock.mockImplementation((_url, _options, callback) => {
|
||||
const response = Readable.from(body ? [Buffer.from(body)] : []) as Readable & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
response.statusCode = statusCode;
|
||||
response.headers = headers;
|
||||
callback(response);
|
||||
return { on: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
}
|
||||
|
||||
describe("fetchJobPostingText", () => {
|
||||
beforeEach(() => {
|
||||
lookupMock.mockReset();
|
||||
requestMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects private IP URLs before fetching", async () => {
|
||||
await expect(fetchJobPostingText("http://127.0.0.1/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects hostnames that resolve to private addresses", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts DNS lookup failures to bad requests", async () => {
|
||||
lookupMock.mockRejectedValue(new Error("ENOTFOUND"));
|
||||
|
||||
await expect(fetchJobPostingText("https://missing.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects redirects instead of following them", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
mockRequestResponse(302, { location: "/" });
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects oversized pages before reading the body", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
mockRequestResponse(200, { "content-length": "200001", "content-type": "text/html" }, "ignored");
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("pins the request lookup to the validated public address", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
let pinnedAddress: string | undefined;
|
||||
requestMock.mockImplementation((_url, options, callback) => {
|
||||
options.lookup("jobs.example", {}, (_error: Error | null, address: string) => {
|
||||
pinnedAddress = address;
|
||||
});
|
||||
const response = Readable.from([
|
||||
Buffer.from("<html><script>nope</script><body><h1>Senior Engineer</h1></body></html>"),
|
||||
]) as Readable & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
response.statusCode = 200;
|
||||
response.headers = { "content-type": "text/html" };
|
||||
callback(response);
|
||||
return { on: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).resolves.toBe("Senior Engineer");
|
||||
expect(pinnedAddress).toBe("93.184.216.34");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autofillInputSchema", () => {
|
||||
it("rejects oversized pasted job descriptions", () => {
|
||||
expect(() => autofillInputSchema.parse({ jobDescription: "x".repeat(20_001) })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { IncomingHttpHeaders, IncomingMessage } from "node:http";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
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;
|
||||
const MAX_JOB_POSTING_BYTES = 200_000;
|
||||
const MAX_PASTED_JOB_DESCRIPTION_CHARS = 20_000;
|
||||
const JOB_POSTING_CONTENT_TYPES = ["text/html", "text/plain", "application/xhtml+xml", "application/xml", "text/xml"];
|
||||
type ValidatedAddress = { address: string; family: 4 | 6 };
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string) {
|
||||
const parts = address.split(".").map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
||||
const [a = 0, b = 0] = parts;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
a >= 224
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateAddress(address: string) {
|
||||
if (address.startsWith("::ffff:")) return isPrivateIPv4(address.slice(7));
|
||||
if (isIP(address) === 4) return isPrivateIPv4(address);
|
||||
|
||||
const normalized = address.toLowerCase();
|
||||
return (
|
||||
normalized === "::1" ||
|
||||
normalized === "::" ||
|
||||
normalized.startsWith("fc") ||
|
||||
normalized.startsWith("fd") ||
|
||||
normalized.startsWith("fe8") ||
|
||||
normalized.startsWith("fe9") ||
|
||||
normalized.startsWith("fea") ||
|
||||
normalized.startsWith("feb")
|
||||
);
|
||||
}
|
||||
|
||||
async function assertPublicHttpUrl(url: string): Promise<{ parsed: URL; address: ValidatedAddress }> {
|
||||
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." });
|
||||
}
|
||||
if (parsed.hostname.toLowerCase() === "localhost") {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Local job posting URLs are not supported." });
|
||||
}
|
||||
|
||||
const addresses = isIP(parsed.hostname)
|
||||
? [{ address: parsed.hostname, family: isIP(parsed.hostname) as 4 | 6 }]
|
||||
: ((await lookup(parsed.hostname, { all: true, verbatim: true })) as ValidatedAddress[]);
|
||||
if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Private or local job posting URLs are not supported." });
|
||||
}
|
||||
|
||||
const [address] = addresses;
|
||||
if (!address) throw new ORPCError("BAD_REQUEST", { message: "The job posting URL could not be resolved." });
|
||||
return { parsed, address };
|
||||
}
|
||||
|
||||
function headerValue(headers: IncomingHttpHeaders, name: string) {
|
||||
const value = headers[name];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
async function readTextResponse(response: IncomingMessage) {
|
||||
const contentType = headerValue(response.headers, "content-type")?.split(";")[0]?.trim().toLowerCase();
|
||||
if (contentType && !JOB_POSTING_CONTENT_TYPES.includes(contentType)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "The job posting URL did not return a text page." });
|
||||
}
|
||||
|
||||
const contentLength = Number(headerValue(response.headers, "content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_JOB_POSTING_BYTES) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "The job posting page is too large. Paste the description instead.",
|
||||
});
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
|
||||
for await (const value of response) {
|
||||
const chunk = typeof value === "string" ? Buffer.from(value) : value;
|
||||
total += chunk.byteLength;
|
||||
if (total > MAX_JOB_POSTING_BYTES) {
|
||||
response.destroy();
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "The job posting page is too large. Paste the description instead.",
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
function requestJobPosting(parsed: URL, address: ValidatedAddress, signal: AbortSignal) {
|
||||
return new Promise<IncomingMessage>((resolve, reject) => {
|
||||
const client = parsed.protocol === "https:" ? https : http;
|
||||
const request = client.request(
|
||||
parsed,
|
||||
{
|
||||
signal,
|
||||
headers: {
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
},
|
||||
lookup: (_hostname, _options, callback) => callback(null, address.address, address.family),
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Best-effort fetch + strip of a job posting page. http(s) only, size/time capped.
|
||||
export async function fetchJobPostingText(url: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const { parsed, address } = await assertPublicHttpUrl(url);
|
||||
const response = await requestJobPosting(parsed, address, controller.signal);
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Redirecting job posting URLs are not supported." });
|
||||
}
|
||||
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: `Couldn't fetch the posting (HTTP ${response.statusCode ?? "unknown"}).`,
|
||||
});
|
||||
}
|
||||
const html = await readTextResponse(response);
|
||||
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(),
|
||||
});
|
||||
|
||||
export const autofillInputSchema = z.object({
|
||||
sourceUrl: z.string().optional(),
|
||||
jobDescription: z.string().max(MAX_PASTED_JOB_DESCRIPTION_CHARS).optional(),
|
||||
});
|
||||
|
||||
// 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(autofillInputSchema)
|
||||
.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 1–2 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,199 @@
|
||||
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.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. Requires authentication.",
|
||||
successDescription: "Aggregate application counts.",
|
||||
})
|
||||
.input(applicationDto.stats.input)
|
||||
.output(applicationDto.stats.output)
|
||||
.handler(async ({ context }) => {
|
||||
return applicationService.stats({ 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,17 @@
|
||||
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,
|
||||
tags: crudRouter.tags,
|
||||
ai: aiRouter,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
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(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
const resumeGetByIdMock = vi.hoisted(() => vi.fn());
|
||||
const storageDeleteMock = vi.hoisted(() => 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",
|
||||
resumeFileUrl: "resume_file_url",
|
||||
coverLetterUrl: "cover_letter_url",
|
||||
},
|
||||
}));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
and: (...a: unknown[]) => a,
|
||||
arrayContains: (...a: unknown[]) => a,
|
||||
desc: (x: unknown) => x,
|
||||
eq: (...a: unknown[]) => a,
|
||||
inArray: (...a: unknown[]) => a,
|
||||
sql: Object.assign((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }), {
|
||||
join: (values: unknown[]) => values,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../resume/service", () => ({
|
||||
resumeService: { getById: resumeGetByIdMock },
|
||||
}));
|
||||
vi.mock("../storage/service", () => ({
|
||||
getStorageService: () => ({ delete: storageDeleteMock }),
|
||||
}));
|
||||
|
||||
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() }],
|
||||
resumeFileUrl: "http://localhost:3000/api/uploads/user-1/pictures/resume.pdf",
|
||||
coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dbMock.select.mockReset();
|
||||
dbMock.insert.mockReset();
|
||||
dbMock.update.mockReset();
|
||||
dbMock.delete.mockReset();
|
||||
resumeGetByIdMock.mockReset();
|
||||
storageDeleteMock.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 }]) }) });
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("checks linked resume ownership before inserting", async () => {
|
||||
const values = vi.fn(() => Promise.resolve());
|
||||
dbMock.insert.mockReturnValue({ values });
|
||||
|
||||
await applicationService.create({
|
||||
userId: "user-1",
|
||||
company: "Stripe",
|
||||
role: "Engineer",
|
||||
resumeId: "resume-1",
|
||||
});
|
||||
|
||||
expect(resumeGetByIdMock).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
|
||||
expect(values).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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: unknown }]];
|
||||
expect(arg.activity).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not rewrite activity 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).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks linked resume ownership before updating", async () => {
|
||||
captureSet();
|
||||
await applicationService.update({ id: "app-1", userId: "user-1", resumeId: "resume-1" });
|
||||
|
||||
expect(resumeGetByIdMock).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applicationService.delete", () => {
|
||||
it("deletes owned uploaded attachments after deleting the application", async () => {
|
||||
dbMock.delete.mockReturnValue({
|
||||
where: () => ({ returning: () => Promise.resolve([{ id: "app-1" }]) }),
|
||||
});
|
||||
|
||||
await applicationService.delete({ id: "app-1", userId: "user-1" });
|
||||
|
||||
expect(storageDeleteMock).toHaveBeenCalledWith("uploads/user-1/pictures/resume.pdf");
|
||||
expect(storageDeleteMock).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,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
});
|
||||
dbMock.delete.mockReturnValue({
|
||||
where: () => ({ returning: () => Promise.resolve([{ id: "app-1" }, { id: "app-2" }]) }),
|
||||
});
|
||||
|
||||
const result = await applicationService.bulkDelete({ userId: "user-1", ids: ["app-1", "app-2"] });
|
||||
|
||||
expect(result).toEqual({ deleted: 2 });
|
||||
expect(storageDeleteMock).toHaveBeenCalledWith("uploads/user-1/pictures/resume.pdf");
|
||||
expect(storageDeleteMock).toHaveBeenCalledWith("uploads/user-1/pictures/cover.pdf");
|
||||
expect(storageDeleteMock).not.toHaveBeenCalledWith("uploads/user-2/pictures/ignored.pdf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
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";
|
||||
import { resumeService } from "../resume/service";
|
||||
import { getStorageService } from "../storage/service";
|
||||
|
||||
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;
|
||||
notes?: string | null | undefined;
|
||||
resumeFileUrl?: string | null | undefined;
|
||||
resumeFileName?: string | null | undefined;
|
||||
coverLetterUrl?: string | null | undefined;
|
||||
coverLetterName?: 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;
|
||||
}
|
||||
|
||||
async function assertOwnedResume(userId: string, resumeId: string | null | undefined) {
|
||||
if (!resumeId) return;
|
||||
await resumeService.getById({ id: resumeId, userId });
|
||||
}
|
||||
|
||||
async function assertOwnedResumes(userId: string, resumeIds: (string | null | undefined)[]) {
|
||||
const uniqueResumeIds = [...new Set(resumeIds.filter((id): id is string => !!id))];
|
||||
await Promise.all(uniqueResumeIds.map((resumeId) => assertOwnedResume(userId, resumeId)));
|
||||
}
|
||||
|
||||
function storageKeyFromApplicationUrl(userId: string, value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = value.startsWith("/") ? value : new URL(value).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = pathname.match(/^\/(?:api\/)?uploads\/(.+)$/);
|
||||
if (!match?.[1]) return null;
|
||||
|
||||
const key = `uploads/${match[1]}`;
|
||||
return key.startsWith(`uploads/${userId}/`) ? key : null;
|
||||
}
|
||||
|
||||
async function deleteApplicationAttachments(
|
||||
userId: string,
|
||||
applications: { resumeFileUrl?: string | null; coverLetterUrl?: string | null }[],
|
||||
) {
|
||||
const keys = [
|
||||
...new Set(
|
||||
applications.flatMap((application) => [
|
||||
storageKeyFromApplicationUrl(userId, application.resumeFileUrl),
|
||||
storageKeyFromApplicationUrl(userId, application.coverLetterUrl),
|
||||
]),
|
||||
),
|
||||
].filter((key): key is string => !!key);
|
||||
|
||||
if (keys.length === 0) return;
|
||||
const storageService = getStorageService();
|
||||
await Promise.allSettled(keys.map((key) => storageService.delete(key)));
|
||||
}
|
||||
|
||||
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; 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.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 assertOwnedResume(userId, fields.resumeId);
|
||||
|
||||
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 };
|
||||
|
||||
await assertOwnedResumes(
|
||||
input.userId,
|
||||
input.items.map((item) => item.resumeId),
|
||||
);
|
||||
|
||||
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;
|
||||
},
|
||||
) => {
|
||||
await requireOwned(input.id, input.userId);
|
||||
|
||||
const { id, userId, status, archived, ...fields } = input;
|
||||
await assertOwnedResume(userId, fields.resumeId);
|
||||
|
||||
// Append in SQL so concurrent notes/stage events are not overwritten by a stale array.
|
||||
const activityExpr =
|
||||
status !== undefined
|
||||
? sql`case when ${schema.application.status} <> ${status}
|
||||
then ${schema.application.activity} || ${JSON.stringify([activityEvent("stage", `Moved to ${stageLabel(status)}`)])}::jsonb
|
||||
else ${schema.application.activity} end`
|
||||
: undefined;
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.application)
|
||||
.set({
|
||||
...fields,
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(archived !== undefined ? { archived } : {}),
|
||||
...(activityExpr ? { activity: activityExpr } : {}),
|
||||
})
|
||||
.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 existing = await requireOwned(input.id, input.userId);
|
||||
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");
|
||||
await deleteApplicationAttachments(input.userId, [existing]);
|
||||
},
|
||||
|
||||
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 existing = await db
|
||||
.select()
|
||||
.from(schema.application)
|
||||
.where(and(inArray(schema.application.id, input.ids), eq(schema.application.userId, input.userId)));
|
||||
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 });
|
||||
await deleteApplicationAttachments(
|
||||
input.userId,
|
||||
existing.filter((application) => rows.some((row) => row.id === application.id)),
|
||||
);
|
||||
return { deleted: rows.length };
|
||||
},
|
||||
|
||||
// Raw counts for Insights; funnel/sankey/tiles are derived client-side from these.
|
||||
stats: async (input: { userId: string }) => {
|
||||
const scope = and(eq(schema.application.userId, input.userId), eq(schema.application.archived, false));
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
|
||||
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));
|
||||
},
|
||||
};
|
||||
@@ -55,9 +55,21 @@ const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
||||
|
||||
const IMAGE_MIME_TYPES = ["image/gif", "image/png", "image/jpeg", "image/webp"];
|
||||
|
||||
function buildPictureKey(userId: string): string {
|
||||
const timestamp = Date.now();
|
||||
return `uploads/${userId}/pictures/${timestamp}.jpeg`;
|
||||
const EXTENSION_BY_CONTENT_TYPE: Record<string, string> = {
|
||||
"image/jpeg": "jpeg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"application/pdf": "pdf",
|
||||
};
|
||||
|
||||
// Derive the stored key's extension from the content type so the static handler serves each
|
||||
// file correctly instead of mislabeling it. Images normally arrive as JPEG (sharp), but with
|
||||
// FLAG_DISABLE_IMAGE_PROCESSING they keep their original type — hence all image types are
|
||||
// mapped, not just JPEG. Non-image uploads (e.g. a cover-letter PDF) get their real extension.
|
||||
function buildFileKey(userId: string, contentType: string): string {
|
||||
const extension = EXTENSION_BY_CONTENT_TYPE[contentType] ?? "bin";
|
||||
return `uploads/${userId}/pictures/${Date.now()}.${extension}`;
|
||||
}
|
||||
|
||||
function buildPublicUrl(path: string): string {
|
||||
@@ -337,9 +349,8 @@ interface UploadFileResult {
|
||||
key: string;
|
||||
}
|
||||
|
||||
// ponytail: only "picture" uploads exist in production; screenshot/pdf types were speculative dead code
|
||||
export async function uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
|
||||
const key = buildPictureKey(input.userId);
|
||||
const key = buildFileKey(input.userId, input.contentType);
|
||||
await getStorageService().write({ key, data: input.data, contentType: input.contentType });
|
||||
return { key, url: buildPublicUrl(key) };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user