feat(applications): add contact email and phone (#3396)

* feat(applications): add contact email and phone

* fix(applications): validate imported contact emails

* fix(applications): validate all imported contact fields

* fix(applications): preserve data when contact validation fails

* test(applications): complete contact export fixture

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Emanuele Tonello
2026-09-05 09:51:21 -07:00
committed by GitHub
co-authored by Amruth Pillai
parent 8df1b25550
commit 1f0844b39c
8 changed files with 195 additions and 27 deletions
@@ -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 <form>, 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<HTMLInputElement>) => {
if (event.key === "Enter") add();
};
return (
<div className="flex flex-col gap-2">
{contacts.map((contact, i) => (
@@ -579,6 +594,22 @@ function ContactsEditor({ contacts, pending, onChange }: ContactsEditorProps) {
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{contact.name}</div>
{contact.role && <div className="truncate text-muted-foreground text-xs">{contact.role}</div>}
{contact.email && (
<a
href={`mailto:${encodeURIComponent(contact.email)}`}
className="block truncate text-primary text-xs hover:underline"
>
{contact.email}
</a>
)}
{contact.phone && (
<a
href={`tel:${encodeURIComponent(contact.phone)}`}
className="block truncate text-primary text-xs hover:underline"
>
{contact.phone}
</a>
)}
</div>
{contact.type && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{contact.type}</span>
@@ -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}
/>
<div className="grid grid-cols-2 gap-2">
<Input
value={draft.role}
placeholder={t`Role (optional)`}
onChange={(event) => setDraft((d) => ({ ...d, role: event.target.value }))}
onKeyDown={submitOnEnter}
/>
<Input
value={draft.type}
list="contact-types"
placeholder={t`Label`}
onChange={(event) => setDraft((d) => ({ ...d, type: event.target.value }))}
onKeyDown={submitOnEnter}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Input
type="email"
value={draft.email}
placeholder={t`Email (optional)`}
onChange={(event) => {
setError("");
setDraft((d) => ({ ...d, email: event.target.value }));
}}
onKeyDown={submitOnEnter}
/>
<Input
type="tel"
value={draft.phone}
placeholder={t`Phone (optional)`}
onChange={(event) => setDraft((d) => ({ ...d, phone: event.target.value }))}
onKeyDown={submitOnEnter}
/>
</div>
<datalist id="contact-types">
@@ -625,6 +675,7 @@ function ContactsEditor({ contacts, pending, onChange }: ContactsEditorProps) {
<option value="Referral" />
<option value="Interviewer" />
</datalist>
{error && <p className="text-destructive text-xs">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="button" size="sm" variant="ghost" onClick={reset}>
<Trans>Cancel</Trans>
@@ -22,7 +22,7 @@ import { applicationsListQueryKey } from "../queries";
const MAX_IMPORT = 500;
const SAMPLE =
"Company,Role,Stage,Stage Date,Location,Salary,Source,Tags\nStripe,Frontend Engineer,applied,2026-07-01,Remote,$180k,LinkedIn,remote;react";
"Company,Role,Stage,Stage Date,Location,Salary,Source,Tags,Contact Name,Contact Email,Contact Phone\nStripe,Frontend Engineer,applied,2026-07-01,Remote,$180k,LinkedIn,remote;react,Jane Doe,jane@example.com,+1 555 0100";
type Props = {
open: boolean;
@@ -74,8 +74,8 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
</SheetTitle>
<SheetDescription>
<Trans>
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Stage Date, Salary, Source and
Tags.
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Stage Date, Salary, Source, Tags,
Contact Name, Contact Email and Contact Phone.
</Trans>
</SheetDescription>
</SheetHeader>
@@ -118,10 +118,18 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
<Trans>{importable.length} ready to import</Trans>
{parsed.skipped > 0 && (
<span className="text-muted-foreground text-xs">
· <Trans>{parsed.skipped} skipped (missing company/role)</Trans>
· <Trans>{parsed.skipped} skipped (invalid or missing data)</Trans>
</span>
)}
</div>
{parsed.contactsSkipped > 0 && (
<p className="mt-1.5 text-muted-foreground text-xs">
<Trans>
{parsed.contactsSkipped} contact(s) skipped (invalid email or missing Contact Name). Those
applications still import.
</Trans>
</p>
)}
{overflow > 0 && (
<p className="mt-1.5 text-amber-600 text-xs dark:text-amber-500">
<Trans>
@@ -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") },
+26 -2
View File
@@ -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);
});
});
+40 -9
View File
@@ -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<string, keyof ParsedApplication> = {
const HEADER_ALIASES: Record<string, keyof CsvApplication> = {
company: "company",
employer: "company",
organization: "company",
@@ -99,6 +108,11 @@ const HEADER_ALIASES: Record<string, keyof ParsedApplication> = {
"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<ParsedApplication> = {};
const record: Partial<CsvApplication> = {};
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 = {
@@ -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
<Step title="Fix skipped rows">
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.
</Step>
<Step title="Import">
@@ -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.
+34
View File
@@ -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" });
+6
View File
@@ -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<typeof contactSchema>;