mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 06:42:15 +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:
@@ -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()),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user