diff --git a/apps/web/src/features/applications/components/application-detail-sheet.tsx b/apps/web/src/features/applications/components/application-detail-sheet.tsx index 5c3b2969e..47d98e3a8 100644 --- a/apps/web/src/features/applications/components/application-detail-sheet.tsx +++ b/apps/web/src/features/applications/components/application-detail-sheet.tsx @@ -14,7 +14,7 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useState } from "react"; -import { STAGES } from "@reactive-resume/schema/applications/data"; +import { contactSchema, STAGES } from "@reactive-resume/schema/applications/data"; import { Button } from "@reactive-resume/ui/components/button"; import { Dialog, @@ -55,6 +55,8 @@ const currentStageAnchorId = (activity: ApplicationTimelineEntry[], status: Appl const latestStageDate = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) => [...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.at; +const INITIAL_CONTACT_DRAFT = { name: "", role: "", type: "", email: "", phone: "" }; + type Props = { application: Application | null; onOpenChange: (open: boolean) => void; @@ -553,22 +555,35 @@ type ContactsEditorProps = { function ContactsEditor({ contacts, pending, onChange }: ContactsEditorProps) { const [adding, setAdding] = useState(false); - const [draft, setDraft] = useState({ name: "", role: "", type: "" }); + const [draft, setDraft] = useState(INITIAL_CONTACT_DRAFT); + const [error, setError] = useState(""); const reset = () => { - setDraft({ name: "", role: "", type: "" }); + setDraft(INITIAL_CONTACT_DRAFT); + setError(""); setAdding(false); }; + // The inputs aren't in a
, so `type="email"` never runs native constraint validation. + // Parse with the schema the API enforces, otherwise a typo'd email is rejected server-side + // after reset() has already thrown the draft away, leaving only a generic error toast. const add = () => { - const name = draft.name.trim(); - if (!name) return; - onChange([...contacts, { name, role: draft.role.trim(), type: draft.type.trim() }]); + if (pending || !draft.name.trim()) return; + const parsed = contactSchema.safeParse(draft); + if (!parsed.success) { + setError(t`Enter a valid email address.`); + return; + } + onChange([...contacts, parsed.data]); reset(); }; const removeAt = (index: number) => onChange(contacts.filter((_, i) => i !== index)); + const submitOnEnter = (event: React.KeyboardEvent) => { + if (event.key === "Enter") add(); + }; + return (
{contacts.map((contact, i) => ( @@ -579,6 +594,22 @@ function ContactsEditor({ contacts, pending, onChange }: ContactsEditorProps) {
{contact.name}
{contact.role &&
{contact.role}
} + {contact.email && ( + + {contact.email} + + )} + {contact.phone && ( + + {contact.phone} + + )}
{contact.type && ( {contact.type} @@ -602,21 +633,40 @@ function ContactsEditor({ contacts, pending, onChange }: ContactsEditorProps) { placeholder={t`Name`} autoFocus onChange={(event) => setDraft((d) => ({ ...d, name: event.target.value }))} - onKeyDown={(event) => { - if (event.key === "Enter") add(); - }} + onKeyDown={submitOnEnter} />
setDraft((d) => ({ ...d, role: event.target.value }))} + onKeyDown={submitOnEnter} /> setDraft((d) => ({ ...d, type: event.target.value }))} + onKeyDown={submitOnEnter} + /> +
+
+ { + setError(""); + setDraft((d) => ({ ...d, email: event.target.value })); + }} + onKeyDown={submitOnEnter} + /> + setDraft((d) => ({ ...d, phone: event.target.value }))} + onKeyDown={submitOnEnter} />
@@ -625,6 +675,7 @@ function ContactsEditor({ contacts, pending, onChange }: ContactsEditorProps) { + {error &&

{error}

}
+ {parsed.contactsSkipped > 0 && ( +

+ + {parsed.contactsSkipped} contact(s) skipped (invalid email or missing Contact Name). Those + applications still import. + +

+ )} {overflow > 0 && (

diff --git a/apps/web/src/features/applications/csv-export.test.ts b/apps/web/src/features/applications/csv-export.test.ts index 2a624315d..a0896f9c4 100644 --- a/apps/web/src/features/applications/csv-export.test.ts +++ b/apps/web/src/features/applications/csv-export.test.ts @@ -14,7 +14,7 @@ const application: Application = { sourceUrl: "https://example.com/job", notes: "First line\nSecond line", tags: ["remote", "typescript"], - contacts: [{ name: "Ada", role: "Recruiter", type: "Referral" }], + contacts: [{ name: "Ada", role: "Recruiter", type: "Referral", email: "ada@example.com", phone: "+49 30 123456" }], activity: [ { id: "interview", type: "stage", stage: "interview", at: new Date("2026-08-12T12:00:00Z") }, { id: "note", type: "note", text: "Called recruiter", at: new Date("2026-08-08T12:00:00Z") }, diff --git a/apps/web/src/features/applications/csv.test.ts b/apps/web/src/features/applications/csv.test.ts index e1794adc8..21bb21a69 100644 --- a/apps/web/src/features/applications/csv.test.ts +++ b/apps/web/src/features/applications/csv.test.ts @@ -27,7 +27,7 @@ describe("parseCsv", () => { describe("mapCsvToApplications", () => { it("maps aliased headers and coerces status/tags", () => { const csv = - 'Company,Job Title,Stage,Stage Date,Salary,Tags\nStripe,Frontend,Interview,2026-07-01,$180k,"remote;react"'; + 'Company,Job Title,Stage,Stage Date,Salary,Tags,Contact Name,Contact Email,Contact Phone\nStripe,Frontend,Interview,2026-07-01,$180k,"remote;react",Jane Doe,jane@example.com,+1 555 0100'; const { rows, recognized } = mapCsvToApplications(parseCsv(csv)); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ @@ -37,9 +37,10 @@ describe("mapCsvToApplications", () => { stageEnteredAt: "2026-07-01", salary: "$180k", tags: ["remote", "react"], + contacts: [{ name: "Jane Doe", role: "", type: "", email: "jane@example.com", phone: "+1 555 0100" }], }); expect(recognized).toEqual( - expect.arrayContaining(["company", "role", "status", "stageEnteredAt", "salary", "tags"]), + expect.arrayContaining(["company", "role", "status", "stageEnteredAt", "salary", "tags", "contactEmail"]), ); }); @@ -52,4 +53,27 @@ describe("mapCsvToApplications", () => { expect(rows[0]?.stageEnteredAt).toBeUndefined(); // invalid date dropped expect(skipped).toBe(2); }); + + it("keeps the application and drops only the contact when the email is malformed", () => { + const { rows, skipped, contactsSkipped } = mapCsvToApplications( + parseCsv("Company,Role,Contact Name,Contact Email\nStripe,Eng,Jane Doe,not-an-email"), + ); + + expect(rows).toHaveLength(1); + expect(rows[0]?.company).toBe("Stripe"); + expect(rows[0]?.contacts).toBeUndefined(); + expect(skipped).toBe(0); + expect(contactsSkipped).toBe(1); + }); + + it("keeps the application when contact fields are present but the contact name is missing", () => { + const { rows, skipped, contactsSkipped } = mapCsvToApplications( + parseCsv("Company,Role,Contact Email,Contact Phone\nStripe,Eng,jane@example.com,+1 555 0100"), + ); + + expect(rows).toHaveLength(1); + expect(rows[0]?.contacts).toBeUndefined(); + expect(skipped).toBe(0); + expect(contactsSkipped).toBe(1); + }); }); diff --git a/apps/web/src/features/applications/csv.ts b/apps/web/src/features/applications/csv.ts index b68307159..305d38ac1 100644 --- a/apps/web/src/features/applications/csv.ts +++ b/apps/web/src/features/applications/csv.ts @@ -1,6 +1,6 @@ -import type { ApplicationStatus } from "@reactive-resume/schema/applications/data"; +import type { ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data"; import type { Application } from "./types"; -import { applicationStatusSchema, STAGES } from "@reactive-resume/schema/applications/data"; +import { applicationStatusSchema, contactSchema, STAGES } from "@reactive-resume/schema/applications/data"; // Minimal RFC-4180-ish CSV parser: handles quoted fields, escaped quotes (""), commas and // newlines inside quotes, and \r\n. Enough for spreadsheet exports; not a full streaming parser. @@ -64,6 +64,15 @@ type ParsedApplication = { sourceUrl?: string; stageEnteredAt?: string; tags?: string[]; + contacts?: Contact[]; +}; + +type CsvApplication = ParsedApplication & { + contactName?: string; + contactRole?: string; + contactType?: string; + contactEmail?: string; + contactPhone?: string; }; function dateOnly(value: string) { @@ -74,7 +83,7 @@ function dateOnly(value: string) { } // Header aliases → canonical field. Matched case-insensitively after trimming. -const HEADER_ALIASES: Record = { +const HEADER_ALIASES: Record = { company: "company", employer: "company", organization: "company", @@ -99,6 +108,11 @@ const HEADER_ALIASES: Record = { "job url": "sourceUrl", "job posting": "sourceUrl", tags: "tags", + "contact name": "contactName", + "contact role": "contactRole", + "contact type": "contactType", + "contact email": "contactEmail", + "contact phone": "contactPhone", }; // Values a spreadsheet would evaluate as a formula: leading =, +, -, @ (and full-width variants), @@ -133,28 +147,32 @@ function parseTags(value: string) { export type CsvMapResult = { rows: ParsedApplication[]; skipped: number; + contactsSkipped: number; headers: string[]; recognized: string[]; }; // Maps parsed CSV rows to application inputs using the header row. Rows missing company or role -// are skipped (and counted). Status is coerced to a valid stage or dropped. +// are skipped (and counted). Status is coerced to a valid stage or dropped. A contact that fails +// validation (bad email, or contact columns with no name) is dropped on its own — the application +// still imports, since losing the whole row would silently discard company/role/salary/tags too. export function mapCsvToApplications(table: string[][]): CsvMapResult { const [headerRow, ...dataRows] = table; - if (!headerRow) return { rows: [], skipped: 0, headers: [], recognized: [] }; + if (!headerRow) return { rows: [], skipped: 0, contactsSkipped: 0, headers: [], recognized: [] }; const headers = headerRow.map((h) => h.trim()); const fieldFor = headers.map((h) => HEADER_ALIASES[h.toLowerCase()]); - const recognized = [...new Set(fieldFor.filter((f): f is keyof ParsedApplication => !!f))]; + const recognized = [...new Set(fieldFor.filter((f): f is keyof CsvApplication => !!f))]; const isReactiveResumeExport = ["Stage History", "Timeline", "Archived", "Created At", "Updated At"].every((header) => headers.includes(header), ); const rows: ParsedApplication[] = []; let skipped = 0; + let contactsSkipped = 0; for (const raw of dataRows) { - const record: Partial = {}; + const record: Partial = {}; fieldFor.forEach((field, i) => { if (!field) return; const rawValue = raw[i] ?? ""; @@ -173,10 +191,23 @@ export function mapCsvToApplications(table: string[][]): CsvMapResult { skipped++; continue; } - rows.push(record as ParsedApplication); + + const { contactName, contactRole, contactType, contactEmail, contactPhone, ...application } = record; + if (contactName || contactRole || contactType || contactEmail || contactPhone) { + const contact = contactSchema.safeParse({ + name: contactName, + role: contactRole ?? "", + type: contactType ?? "", + email: contactEmail ?? "", + phone: contactPhone ?? "", + }); + if (contact.success) application.contacts = [contact.data]; + else contactsSkipped++; + } + rows.push(application as ParsedApplication); } - return { rows, skipped, headers, recognized }; + return { rows, skipped, contactsSkipped, headers, recognized }; } export type ApplicationExportOptions = { diff --git a/docs/guides/importing-applications-from-csv.mdx b/docs/guides/importing-applications-from-csv.mdx index 884d73753..b2ee94a5f 100644 --- a/docs/guides/importing-applications-from-csv.mdx +++ b/docs/guides/importing-applications-from-csv.mdx @@ -42,12 +42,20 @@ Supported headers include: | **Notes** | `Notes`, `Note` | | **Job posting URL** | `URL`, `Link`, `Job URL`, `Job Posting` | | **Tags** | `Tags` | +| **Contact name** | `Contact Name` | +| **Contact role** | `Contact Role` | +| **Contact label** | `Contact Type` | +| **Contact email** | `Contact Email` | +| **Contact phone** | `Contact Phone` | Tags can be separated with commas, semicolons, or vertical bars. +Each row can carry one contact. A contact needs a `Contact Name`, and `Contact Email` must be a valid email address — if +either is wrong, the contact is dropped and the application still imports. + ```csv -Company,Role,Stage,Location,Salary,Source,Tags -Stripe,Frontend Engineer,applied,Remote,$180k,LinkedIn,remote;react +Company,Role,Stage,Location,Salary,Source,Tags,Contact Name,Contact Email,Contact Phone +Stripe,Frontend Engineer,applied,Remote,$180k,LinkedIn,remote;react,Jane Doe,jane@example.com,+1 555 0100 ``` ## Import applications @@ -63,6 +71,7 @@ Stripe,Frontend Engineer,applied,Remote,$180k,LinkedIn,remote;react Rows without a company or role are skipped. Add the missing values before importing if you want those rows included. + The preview also counts skipped contacts — those rows still import, just without the contact. @@ -101,6 +110,11 @@ Reactive Resume imports up to 500 applications at a time. If your CSV has more t Make sure every row has both a company and a role. These fields are required. +### A contact did not import + +The contact needs a `Contact Name`, and `Contact Email` must be a valid address. The application imports either way — fix +the contact columns and import that row again if you want the contact. + ### A column was not recognized Rename the header to one of the recognized names in the table above, then import again. diff --git a/packages/api/src/dto/application.test.ts b/packages/api/src/dto/application.test.ts index 3dac89525..6d82e63bf 100644 --- a/packages/api/src/dto/application.test.ts +++ b/packages/api/src/dto/application.test.ts @@ -35,6 +35,40 @@ describe("applicationDto jobDescription", () => { }); }); +describe("applicationDto contacts", () => { + it("accepts contact email and phone details", () => { + const parsed = applicationDto.create.input.parse({ + company: "Stripe", + role: "Engineer", + contacts: [{ name: "Jane Doe", email: "jane@example.com", phone: "+1 555 0100" }], + }); + + expect(parsed.contacts).toEqual([ + { name: "Jane Doe", role: "", type: "", email: "jane@example.com", phone: "+1 555 0100" }, + ]); + }); + + it("keeps legacy contacts compatible", () => { + const parsed = applicationDto.create.input.parse({ + company: "Stripe", + role: "Engineer", + contacts: [{ name: "Jane Doe" }], + }); + + expect(parsed.contacts?.[0]).toMatchObject({ email: "", phone: "" }); + }); + + it("rejects malformed contact emails", () => { + expect(() => + applicationDto.create.input.parse({ + company: "Stripe", + role: "Engineer", + contacts: [{ name: "Jane Doe", email: "not-an-email" }], + }), + ).toThrow("Invalid email address."); + }); +}); + describe("applicationDto document uploads", () => { it("accepts PDF application documents", () => { const file = new File(["%PDF-1.4"], "resume.pdf", { type: "application/pdf" }); diff --git a/packages/schema/src/applications/data.ts b/packages/schema/src/applications/data.ts index f2302071d..6ccfeeb7d 100644 --- a/packages/schema/src/applications/data.ts +++ b/packages/schema/src/applications/data.ts @@ -22,6 +22,12 @@ export const contactSchema = z.object({ role: z.string().trim().default(""), // Free-form label shown as a pill: "Recruiter", "Referral", "Hiring Manager"… type: z.string().trim().default(""), + email: z + .string() + .trim() + .refine((value) => value === "" || z.email().safeParse(value).success, "Invalid email address.") + .default(""), + phone: z.string().trim().default(""), }); export type Contact = z.infer;