mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
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:
co-authored by
Amruth Pillai
parent
8df1b25550
commit
1f0844b39c
@@ -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") },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user