mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-10 21:15:04 +10:00
feat: add application timeline history (#3237)
* feat: add application timeline history * fix: address application timeline review * fix: keep application tracker e2e stable * fix: use stable timeline e2e selector * fix: target timeline note input in e2e
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
|
||||
import type { ApplicationStatus, ApplicationTimelineEntry, Contact } from "@reactive-resume/schema/applications/data";
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -17,8 +17,17 @@ import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -27,6 +36,24 @@ import { ApplicationAiCopilot } from "./application-ai-copilot";
|
||||
import { FileAttachmentField } from "./file-attachment-field";
|
||||
|
||||
const stageIndex = (status: ApplicationStatus) => STAGES.findIndex((s) => s.value === status);
|
||||
const stageOf = (status: ApplicationStatus) => STAGES.find((s) => s.value === status);
|
||||
|
||||
const dateInputValue = (value: Date | string) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatDate = (value: Date | string) =>
|
||||
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC", year: "numeric" });
|
||||
|
||||
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
|
||||
new Date(b.at).getTime() - new Date(a.at).getTime();
|
||||
|
||||
const currentStageAnchorId = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) =>
|
||||
[...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.id;
|
||||
|
||||
const latestStageDate = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) =>
|
||||
[...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.at;
|
||||
|
||||
type Props = {
|
||||
application: Application | null;
|
||||
@@ -37,17 +64,8 @@ type Props = {
|
||||
export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [note, setNote] = useState("");
|
||||
const id = application?.id;
|
||||
|
||||
// Reset the draft note when switching to a different application (React's "adjust state during
|
||||
// render" pattern) so typed-but-unsent text doesn't leak across applications.
|
||||
const [noteFor, setNoteFor] = useState(id);
|
||||
if (id !== noteFor) {
|
||||
setNoteFor(id);
|
||||
setNote("");
|
||||
}
|
||||
|
||||
const { data } = useQuery({
|
||||
...orpc.applications.getById.queryOptions({ input: { id: id ?? "" } }),
|
||||
enabled: !!id,
|
||||
@@ -71,14 +89,25 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
|
||||
|
||||
const addNote = useMutation(
|
||||
orpc.applications.addNote.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setNote("");
|
||||
invalidate();
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t`Couldn't save the note.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateTimelineEntry = useMutation(
|
||||
orpc.applications.updateTimelineEntry.mutationOptions({
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t`Couldn't update the timeline entry.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const deleteTimelineEntry = useMutation(
|
||||
orpc.applications.deleteTimelineEntry.mutationOptions({
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t`Couldn't delete the timeline entry.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const remove = useMutation(
|
||||
orpc.applications.delete.mutationOptions({
|
||||
onSuccess: () => {
|
||||
@@ -145,7 +174,10 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<Fact label={t`Salary`} value={current.salary} />
|
||||
<Fact label={t`Source`} value={current.source} />
|
||||
<Fact label={t`Applied on`} value={new Date(current.appliedAt).toLocaleDateString()} />
|
||||
<Fact
|
||||
label={t`Applied on`}
|
||||
value={formatDate(latestStageDate(current.activity, "applied") ?? current.appliedAt)}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{current.sourceUrl && (
|
||||
@@ -240,40 +272,21 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* activity timeline */}
|
||||
<Section title={t`Timeline & activity`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...current.activity]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.map((event) => (
|
||||
<div key={event.id} className="flex gap-2.5 text-sm">
|
||||
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground/40" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>{event.text}</div>
|
||||
<div className="text-muted-foreground text-xs">{new Date(event.at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Input
|
||||
value={note}
|
||||
placeholder={t`Add a note or log activity…`}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && note.trim()) addNote.mutate({ id: current.id, text: note.trim() });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!note.trim() || addNote.isPending}
|
||||
onClick={() => addNote.mutate({ id: current.id, text: note.trim() })}
|
||||
>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
<ApplicationTimeline
|
||||
key={current.id}
|
||||
application={current}
|
||||
pending={addNote.isPending || updateTimelineEntry.isPending || deleteTimelineEntry.isPending}
|
||||
onAddNote={(text) => addNote.mutateAsync({ id: current.id, text })}
|
||||
onUpdateEntry={(entryId, input) => updateTimelineEntry.mutateAsync({ id: current.id, entryId, ...input })}
|
||||
onDeleteEntry={(entryId) => {
|
||||
void confirm(t`Delete this timeline entry?`, {
|
||||
description: t`This entry will be permanently deleted. This can't be undone.`,
|
||||
confirmText: t`Delete`,
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) deleteTimelineEntry.mutate({ id: current.id, entryId });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-border border-t p-4">
|
||||
@@ -317,6 +330,206 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
|
||||
);
|
||||
}
|
||||
|
||||
type ApplicationTimelineProps = {
|
||||
application: Application;
|
||||
pending: boolean;
|
||||
onAddNote: (text: string) => Promise<unknown>;
|
||||
onUpdateEntry: (entryId: string, input: { date?: string; text?: string }) => Promise<unknown>;
|
||||
onDeleteEntry: (entryId: string) => void;
|
||||
};
|
||||
|
||||
function ApplicationTimeline({
|
||||
application,
|
||||
pending,
|
||||
onAddNote,
|
||||
onUpdateEntry,
|
||||
onDeleteEntry,
|
||||
}: ApplicationTimelineProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const [editingDate, setEditingDate] = useState<ApplicationTimelineEntry | null>(null);
|
||||
const [editingNote, setEditingNote] = useState<(ApplicationTimelineEntry & { type: "note" }) | null>(null);
|
||||
const [dateDraft, setDateDraft] = useState("");
|
||||
const [noteDraft, setNoteDraft] = useState("");
|
||||
const anchorId = currentStageAnchorId(application.activity, application.status);
|
||||
const sorted = [...application.activity].sort(byNewest);
|
||||
|
||||
const openDate = (entry: ApplicationTimelineEntry) => {
|
||||
setEditingDate(entry);
|
||||
setDateDraft(dateInputValue(entry.at));
|
||||
};
|
||||
|
||||
const openNote = (entry: ApplicationTimelineEntry & { type: "note" }) => {
|
||||
setEditingNote(entry);
|
||||
setNoteDraft(entry.text);
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
if (pending) return;
|
||||
const text = note.trim();
|
||||
if (!text) return;
|
||||
void onAddNote(text)
|
||||
.then(() => setNote(""))
|
||||
.catch(() => false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title={t`Timeline`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
data-timeline-note-input
|
||||
value={note}
|
||||
disabled={pending}
|
||||
placeholder={t`Add a note…`}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" disabled={!note.trim() || pending} onClick={add}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col gap-2 ps-4 before:absolute before:inset-y-2 before:start-1 before:w-px before:bg-border">
|
||||
{sorted.map((entry) => {
|
||||
const stage = entry.type === "stage" ? stageOf(entry.stage) : null;
|
||||
const isAnchor = entry.id === anchorId;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-timeline-entry={entry.type}
|
||||
data-stage={entry.type === "stage" ? entry.stage : undefined}
|
||||
className="group relative rounded-lg border border-border bg-card p-3 text-sm"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute start-[-17px] top-4 size-2.5 rounded-full border-2 border-card",
|
||||
entry.type === "note" && "bg-primary/70",
|
||||
)}
|
||||
style={stage ? { background: stage.color } : undefined}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{entry.type === "stage" ? (
|
||||
<div className="font-medium">
|
||||
<Trans>Moved to</Trans> {stage?.label ?? entry.stage}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded-sm text-left hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => openNote(entry)}
|
||||
>
|
||||
{entry.text}
|
||||
</button>
|
||||
)}
|
||||
{isAnchor && (
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>Current stage</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border px-2 py-1 text-muted-foreground text-xs hover:bg-muted hover:text-foreground"
|
||||
onClick={() => openDate(entry)}
|
||||
>
|
||||
{formatDate(entry.at)}
|
||||
</button>
|
||||
{!isAnchor && (
|
||||
<button
|
||||
type="button"
|
||||
title={t`Delete timeline entry`}
|
||||
disabled={pending}
|
||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 disabled:opacity-40 group-hover:opacity-100"
|
||||
onClick={() => onDeleteEntry(entry.id)}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editingDate} onOpenChange={(open) => !open && setEditingDate(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Edit date</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Update the calendar date for this timeline entry.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateDraft}
|
||||
onInput={(event) => setDateDraft(event.currentTarget.value)}
|
||||
onChange={(event) => setDateDraft(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditingDate(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!dateDraft || pending}
|
||||
onClick={() => {
|
||||
if (!editingDate) return;
|
||||
void onUpdateEntry(editingDate.id, { date: dateDraft })
|
||||
.then(() => setEditingDate(null))
|
||||
.catch(() => false);
|
||||
}}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!editingNote} onOpenChange={(open) => !open && setEditingNote(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Edit note</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Update this timeline note.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea value={noteDraft} rows={4} onChange={(event) => setNoteDraft(event.target.value)} />
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditingNote(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!noteDraft.trim() || pending}
|
||||
onClick={() => {
|
||||
if (!editingNote) return;
|
||||
void onUpdateEntry(editingNote.id, { text: noteDraft.trim() })
|
||||
.then(() => setEditingNote(null))
|
||||
.catch(() => false);
|
||||
}}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -27,8 +27,12 @@ import { FileAttachmentField } from "./file-attachment-field";
|
||||
|
||||
// Preset source suggestions surfaced via a <datalist>; the field itself stays free-text.
|
||||
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company Website", "Referral", "Recruiter", "Other"];
|
||||
const todayInputValue = () => {
|
||||
const now = new Date();
|
||||
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const EMPTY = {
|
||||
const emptyForm = () => ({
|
||||
company: "",
|
||||
role: "",
|
||||
location: "",
|
||||
@@ -38,15 +42,16 @@ const EMPTY = {
|
||||
resumeId: "",
|
||||
tags: [] as string[],
|
||||
sourceUrl: "",
|
||||
stageEnteredAt: todayInputValue(),
|
||||
jobDescription: "",
|
||||
followUpAt: "",
|
||||
followUpNote: "",
|
||||
notes: "",
|
||||
resumeFile: null as FileAttachment | null,
|
||||
coverLetter: null as FileAttachment | null,
|
||||
};
|
||||
});
|
||||
|
||||
type FormState = typeof EMPTY;
|
||||
type FormState = ReturnType<typeof emptyForm>;
|
||||
|
||||
const toAttachment = (url: string | null, name: string | null): FileAttachment | null =>
|
||||
url ? { url, name: name ?? url } : null;
|
||||
@@ -62,6 +67,7 @@ function toForm(app: Application): FormState {
|
||||
resumeId: app.resumeId ?? "",
|
||||
tags: app.tags,
|
||||
sourceUrl: app.sourceUrl ?? "",
|
||||
stageEnteredAt: "",
|
||||
jobDescription: app.jobDescription ?? "",
|
||||
followUpAt: app.followUpAt ? new Date(app.followUpAt).toISOString().slice(0, 10) : "",
|
||||
followUpNote: app.followUpNote ?? "",
|
||||
@@ -82,13 +88,13 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!application;
|
||||
|
||||
const [form, setForm] = useState<FormState>(application ? toForm(application) : EMPTY);
|
||||
const [form, setForm] = useState<FormState>(() => (application ? toForm(application) : emptyForm()));
|
||||
|
||||
// Re-sync the form when the sheet's target changes (a different app, or create ↔ edit).
|
||||
const [syncedId, setSyncedId] = useState(application?.id ?? null);
|
||||
if ((application?.id ?? null) !== syncedId) {
|
||||
setSyncedId(application?.id ?? null);
|
||||
setForm(application ? toForm(application) : EMPTY);
|
||||
setForm(application ? toForm(application) : emptyForm());
|
||||
}
|
||||
|
||||
const { data: resumes } = useQuery(orpc.resume.list.queryOptions());
|
||||
@@ -115,7 +121,7 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application added to your pipeline.`);
|
||||
setForm(EMPTY);
|
||||
setForm(emptyForm());
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't add the application. Please try again.`),
|
||||
@@ -174,7 +180,7 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
coverLetterName: form.coverLetter?.name ?? null,
|
||||
};
|
||||
if (application) update.mutate({ id: application.id, ...payload });
|
||||
else create.mutate(payload);
|
||||
else create.mutate({ ...payload, stageEnteredAt: form.stageEnteredAt || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -270,6 +276,16 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<Field label={t`Stage date`}>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.stageEnteredAt}
|
||||
onChange={(event) => set("stageEnteredAt", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Resume: link a live Reactive Resume (unlocks AI) or upload the exact PDF you sent. */}
|
||||
<Field label={t`Resume`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -22,7 +22,7 @@ import { applicationsListQueryKey } from "../queries";
|
||||
|
||||
const MAX_IMPORT = 500;
|
||||
const SAMPLE =
|
||||
"Company,Role,Stage,Location,Salary,Source,Tags\nStripe,Frontend Engineer,applied,Remote,$180k,LinkedIn,remote;react";
|
||||
"Company,Role,Stage,Stage Date,Location,Salary,Source,Tags\nStripe,Frontend Engineer,applied,2026-07-01,Remote,$180k,LinkedIn,remote;react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -74,7 +74,8 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
<Trans>
|
||||
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Salary, Source and Tags.
|
||||
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Stage Date, Salary, Source and
|
||||
Tags.
|
||||
</Trans>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ApplicationTimelineEntry } from "@reactive-resume/schema/applications/data";
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -9,13 +10,20 @@ import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { computeInsights, computeTimeline } from "../insights";
|
||||
|
||||
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
|
||||
new Date(b.at).getTime() - new Date(a.at).getTime();
|
||||
|
||||
const appliedDate = (app: Application) =>
|
||||
[...app.activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === "applied")?.at ??
|
||||
app.appliedAt;
|
||||
|
||||
export function ApplicationInsights({ applications }: { applications: Application[] }) {
|
||||
const { data } = useQuery(orpc.applications.stats.queryOptions({}));
|
||||
|
||||
// Weekly application velocity — derived from the already-loaded list, matching the stats
|
||||
// population (archived excluded), so no extra endpoint is needed.
|
||||
const timeline = useMemo(
|
||||
() => computeTimeline(applications.filter((app) => !app.archived).map((app) => new Date(app.appliedAt))),
|
||||
() => computeTimeline(applications.filter((app) => !app.archived).map((app) => new Date(appliedDate(app)))),
|
||||
[applications],
|
||||
);
|
||||
const maxWeek = Math.max(1, ...timeline.map((bucket) => bucket.count));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ApplicationStatus, ApplicationTimelineEntry } from "@reactive-resume/schema/applications/data";
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -26,6 +27,12 @@ import { ApplicationActionsMenu } from "./application-actions-menu";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const stageOf = (status: string) => STAGES.find((s) => s.value === status);
|
||||
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
|
||||
new Date(b.at).getTime() - new Date(a.at).getTime();
|
||||
const latestStageDate = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) =>
|
||||
[...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.at;
|
||||
const formatDate = (value: Date | string) =>
|
||||
new Date(value).toLocaleDateString(undefined, { month: "numeric", day: "numeric", timeZone: "UTC", year: "numeric" });
|
||||
|
||||
type Props = {
|
||||
applications: Application[];
|
||||
@@ -265,7 +272,7 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.source || "—"}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
|
||||
{new Date(app.appliedAt).toLocaleDateString()}
|
||||
{formatDate(latestStageDate(app.activity, "applied") ?? app.appliedAt)}
|
||||
</td>
|
||||
<td className="px-1 py-2">
|
||||
<ApplicationActionsMenu application={app} onEdit={onEdit} />
|
||||
|
||||
@@ -26,24 +26,30 @@ describe("parseCsv", () => {
|
||||
|
||||
describe("mapCsvToApplications", () => {
|
||||
it("maps aliased headers and coerces status/tags", () => {
|
||||
const csv = 'Company,Job Title,Stage,Salary,Tags\nStripe,Frontend,Interview,$180k,"remote;react"';
|
||||
const csv =
|
||||
'Company,Job Title,Stage,Stage Date,Salary,Tags\nStripe,Frontend,Interview,2026-07-01,$180k,"remote;react"';
|
||||
const { rows, recognized } = mapCsvToApplications(parseCsv(csv));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
company: "Stripe",
|
||||
role: "Frontend",
|
||||
status: "interview",
|
||||
stageEnteredAt: "2026-07-01",
|
||||
salary: "$180k",
|
||||
tags: ["remote", "react"],
|
||||
});
|
||||
expect(recognized).toEqual(expect.arrayContaining(["company", "role", "status", "salary", "tags"]));
|
||||
expect(recognized).toEqual(
|
||||
expect.arrayContaining(["company", "role", "status", "stageEnteredAt", "salary", "tags"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips rows missing company or role and drops invalid status", () => {
|
||||
const csv = "company,role,status\nStripe,Eng,bogus\n,NoCompany,applied\nAcme,,saved";
|
||||
const csv =
|
||||
"company,role,status,stage date\nStripe,Eng,bogus,2026-99-99\n,NoCompany,applied,2026-07-01\nAcme,,saved,2026-07-01";
|
||||
const { rows, skipped } = mapCsvToApplications(parseCsv(csv));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBeUndefined(); // "bogus" dropped
|
||||
expect(rows[0]?.stageEnteredAt).toBeUndefined(); // invalid date dropped
|
||||
expect(skipped).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,9 +61,17 @@ type ParsedApplication = {
|
||||
source?: string;
|
||||
notes?: string;
|
||||
sourceUrl?: string;
|
||||
stageEnteredAt?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
function dateOnly(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 0));
|
||||
return date.toISOString().slice(0, 10) === value ? value : undefined;
|
||||
}
|
||||
|
||||
// Header aliases → canonical field. Matched case-insensitively after trimming.
|
||||
const HEADER_ALIASES: Record<string, keyof ParsedApplication> = {
|
||||
company: "company",
|
||||
@@ -75,6 +83,9 @@ const HEADER_ALIASES: Record<string, keyof ParsedApplication> = {
|
||||
"job title": "role",
|
||||
status: "status",
|
||||
stage: "status",
|
||||
"applied date": "stageEnteredAt",
|
||||
"stage date": "stageEnteredAt",
|
||||
"stage entered at": "stageEnteredAt",
|
||||
location: "location",
|
||||
salary: "salary",
|
||||
"salary range": "salary",
|
||||
@@ -123,6 +134,8 @@ export function mapCsvToApplications(table: string[][]): CsvMapResult {
|
||||
else if (field === "status") {
|
||||
const parsed = applicationStatusSchema.safeParse(value.toLowerCase());
|
||||
if (parsed.success) record.status = parsed.data;
|
||||
} else if (field === "stageEnteredAt") {
|
||||
record.stageEnteredAt = dateOnly(value);
|
||||
} else record[field] = value as never;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
WITH entries AS (
|
||||
SELECT
|
||||
a.id AS application_id,
|
||||
entry.value AS entry,
|
||||
entry.ordinality,
|
||||
coalesce(nullif(entry.value ->> 'id', ''), md5(a.id || ':' || entry.ordinality::text)) AS entry_id,
|
||||
coalesce(nullif(entry.value ->> 'at', ''), a.applied_at::text, a.created_at::text, now()::text) AS entry_at,
|
||||
lower(coalesce(entry.value ->> 'text', '')) AS entry_text
|
||||
FROM "application" a
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE WHEN jsonb_typeof(a.activity) = 'array' THEN a.activity ELSE '[]'::jsonb END
|
||||
) WITH ORDINALITY AS entry(value, ordinality)
|
||||
),
|
||||
normalized AS (
|
||||
SELECT
|
||||
application_id,
|
||||
ordinality,
|
||||
CASE
|
||||
WHEN entry ->> 'type' = 'stage'
|
||||
AND entry ->> 'stage' IN ('saved', 'applied', 'screening', 'interview', 'offer', 'rejected') THEN
|
||||
jsonb_build_object('id', entry_id, 'type', 'stage', 'stage', entry ->> 'stage', 'at', entry_at)
|
||||
WHEN entry ->> 'type' IN ('created', 'stage')
|
||||
AND CASE
|
||||
WHEN entry_text LIKE '%screening%' THEN 'screening'
|
||||
WHEN entry_text LIKE '%interview%' THEN 'interview'
|
||||
WHEN entry_text LIKE '%offer%' THEN 'offer'
|
||||
WHEN entry_text LIKE '%rejected%' THEN 'rejected'
|
||||
WHEN entry_text LIKE '%applied%' THEN 'applied'
|
||||
WHEN entry_text LIKE '%saved%' THEN 'saved'
|
||||
END IS NOT NULL THEN
|
||||
jsonb_build_object(
|
||||
'id', entry_id,
|
||||
'type', 'stage',
|
||||
'stage', CASE
|
||||
WHEN entry_text LIKE '%screening%' THEN 'screening'
|
||||
WHEN entry_text LIKE '%interview%' THEN 'interview'
|
||||
WHEN entry_text LIKE '%offer%' THEN 'offer'
|
||||
WHEN entry_text LIKE '%rejected%' THEN 'rejected'
|
||||
WHEN entry_text LIKE '%applied%' THEN 'applied'
|
||||
WHEN entry_text LIKE '%saved%' THEN 'saved'
|
||||
END,
|
||||
'at', entry_at
|
||||
)
|
||||
ELSE
|
||||
jsonb_build_object(
|
||||
'id', entry_id,
|
||||
'type', 'note',
|
||||
'text', coalesce(nullif(btrim(entry ->> 'text'), ''), 'Imported timeline entry'),
|
||||
'at', entry_at
|
||||
)
|
||||
END AS normalized_entry
|
||||
FROM entries
|
||||
),
|
||||
grouped AS (
|
||||
SELECT application_id, jsonb_agg(normalized_entry ORDER BY ordinality) AS activity
|
||||
FROM normalized
|
||||
GROUP BY application_id
|
||||
)
|
||||
UPDATE "application" a
|
||||
SET activity = grouped.activity
|
||||
FROM grouped
|
||||
WHERE a.id = grouped.application_id;
|
||||
--> statement-breakpoint
|
||||
UPDATE "application"
|
||||
SET activity = jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'id', md5(id || ':stage'),
|
||||
'type', 'stage',
|
||||
'stage', status,
|
||||
'at', coalesce(applied_at::text, created_at::text, now()::text)
|
||||
)
|
||||
)
|
||||
WHERE jsonb_typeof(activity) IS DISTINCT FROM 'array' OR activity = '[]'::jsonb;
|
||||
--> statement-breakpoint
|
||||
WITH stage_stats AS (
|
||||
SELECT
|
||||
a.id AS application_id,
|
||||
max((entry.value ->> 'at')::timestamptz) FILTER (WHERE entry.value ->> 'type' = 'stage') AS latest_stage_at,
|
||||
max((entry.value ->> 'at')::timestamptz) FILTER (
|
||||
WHERE entry.value ->> 'type' = 'stage' AND entry.value ->> 'stage' = a.status
|
||||
) AS current_stage_at
|
||||
FROM "application" a
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE WHEN jsonb_typeof(a.activity) = 'array' THEN a.activity ELSE '[]'::jsonb END
|
||||
) AS entry(value)
|
||||
GROUP BY a.id
|
||||
),
|
||||
anchors AS (
|
||||
SELECT
|
||||
a.id,
|
||||
a.status,
|
||||
greatest(
|
||||
coalesce(stage_stats.latest_stage_at, a.applied_at, a.created_at, now()),
|
||||
coalesce(a.applied_at, a.created_at, now())
|
||||
) AS anchor_at,
|
||||
stage_stats.latest_stage_at,
|
||||
stage_stats.current_stage_at
|
||||
FROM "application" a
|
||||
LEFT JOIN stage_stats ON stage_stats.application_id = a.id
|
||||
WHERE jsonb_typeof(a.activity) = 'array'
|
||||
)
|
||||
UPDATE "application" a
|
||||
SET activity = a.activity || jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'id', md5(a.id || ':stage:' || a.status || ':anchor'),
|
||||
'type', 'stage',
|
||||
'stage', a.status,
|
||||
'at', anchors.anchor_at::text
|
||||
)
|
||||
)
|
||||
FROM anchors
|
||||
WHERE a.id = anchors.id
|
||||
AND (
|
||||
anchors.current_stage_at IS NULL
|
||||
OR (anchors.latest_stage_at IS NOT NULL AND anchors.current_stage_at < anchors.latest_stage_at)
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,9 @@ import { createSelectSchema } from "drizzle-zod";
|
||||
import z from "zod";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import {
|
||||
activityEventSchema,
|
||||
aiMetadataSchema,
|
||||
applicationStatusSchema,
|
||||
applicationTimelineEntrySchema,
|
||||
contactSchema,
|
||||
} from "@reactive-resume/schema/applications/data";
|
||||
|
||||
@@ -12,6 +12,7 @@ const MAX_APPLICATION_JOB_DESCRIPTION_CHARS = 20_000;
|
||||
const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
|
||||
const timelineDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must use YYYY-MM-DD format.");
|
||||
|
||||
const applicationDocumentFileSchema = z
|
||||
.file()
|
||||
@@ -61,7 +62,7 @@ const applicationSchema = createSelectSchema(schema.application, {
|
||||
followUpNote: z.string().trim().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
contacts: z.array(contactSchema),
|
||||
activity: z.array(activityEventSchema),
|
||||
activity: z.array(applicationTimelineEntrySchema),
|
||||
appliedAt: z.date(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
@@ -94,6 +95,7 @@ const createInputSchema = editableSchema.partial().extend({
|
||||
company: applicationSchema.shape.company,
|
||||
role: applicationSchema.shape.role,
|
||||
status: applicationStatusSchema.optional(),
|
||||
stageEnteredAt: timelineDateSchema.optional(),
|
||||
});
|
||||
|
||||
export const applicationDto = {
|
||||
@@ -150,7 +152,24 @@ export const applicationDto = {
|
||||
},
|
||||
|
||||
addNote: {
|
||||
input: z.object({ id: z.string(), text: z.string().trim().min(1) }),
|
||||
input: z.object({ id: z.string(), text: z.string().trim().min(1), date: timelineDateSchema.optional() }),
|
||||
output: applicationSchema.omit({ userId: true }),
|
||||
},
|
||||
|
||||
updateTimelineEntry: {
|
||||
input: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
entryId: z.string(),
|
||||
date: timelineDateSchema.optional(),
|
||||
text: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.refine((value) => value.date !== undefined || value.text !== undefined, "Provide date or text to update."),
|
||||
output: applicationSchema.omit({ userId: true }),
|
||||
},
|
||||
|
||||
deleteTimelineEntry: {
|
||||
input: z.object({ id: z.string(), entryId: z.string() }),
|
||||
output: applicationSchema.omit({ userId: true }),
|
||||
},
|
||||
|
||||
|
||||
@@ -171,7 +171,42 @@ export const crudRouter = {
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.addNote.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text });
|
||||
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text, date: input.date });
|
||||
}),
|
||||
|
||||
updateTimelineEntry: protectedProcedure
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/applications/{id}/timeline/{entryId}",
|
||||
tags: ["Applications"],
|
||||
operationId: "updateApplicationTimelineEntry",
|
||||
summary: "Update a timeline entry",
|
||||
description: "Updates a timeline entry date, or note text for note entries. Requires authentication.",
|
||||
successDescription: "The updated application.",
|
||||
})
|
||||
.input(applicationDto.updateTimelineEntry.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.updateTimelineEntry.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.updateTimelineEntry({ ...input, userId: context.user.id });
|
||||
}),
|
||||
|
||||
deleteTimelineEntry: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/applications/{id}/timeline/{entryId}",
|
||||
tags: ["Applications"],
|
||||
operationId: "deleteApplicationTimelineEntry",
|
||||
summary: "Delete a timeline entry",
|
||||
description:
|
||||
"Deletes a note or older stage entry. The current stage entry cannot be deleted. Requires authentication.",
|
||||
successDescription: "The updated application.",
|
||||
})
|
||||
.input(applicationDto.deleteTimelineEntry.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.deleteTimelineEntry.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.deleteTimelineEntry({ ...input, userId: context.user.id });
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
|
||||
@@ -10,6 +10,8 @@ export const applicationsRouter = {
|
||||
attachDocument: crudRouter.attachDocument,
|
||||
removeDocument: crudRouter.removeDocument,
|
||||
addNote: crudRouter.addNote,
|
||||
updateTimelineEntry: crudRouter.updateTimelineEntry,
|
||||
deleteTimelineEntry: crudRouter.deleteTimelineEntry,
|
||||
delete: crudRouter.delete,
|
||||
bulkUpdate: crudRouter.bulkUpdate,
|
||||
bulkDelete: crudRouter.bulkDelete,
|
||||
|
||||
@@ -6,6 +6,8 @@ const dbMock = vi.hoisted(() => ({
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}));
|
||||
const resumeGetByIdMock = vi.hoisted(() => vi.fn());
|
||||
const storageDeleteMock = vi.hoisted(() => vi.fn());
|
||||
@@ -17,6 +19,8 @@ vi.mock("@reactive-resume/db/schema", () => ({
|
||||
id: "id",
|
||||
userId: "user_id",
|
||||
status: "status",
|
||||
activity: "activity",
|
||||
appliedAt: "applied_at",
|
||||
updatedAt: "updated_at",
|
||||
resumeFileUrl: "resume_file_url",
|
||||
coverLetterUrl: "cover_letter_url",
|
||||
@@ -48,7 +52,9 @@ const existing = {
|
||||
company: "Stripe",
|
||||
role: "Engineer",
|
||||
status: "saved" as const,
|
||||
activity: [{ id: "e0", type: "created" as const, text: "Added to Saved", at: new Date() }],
|
||||
activity: [{ id: "e0", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T12:00:00.000Z") }],
|
||||
appliedAt: new Date("2026-07-01T12:00:00.000Z"),
|
||||
createdAt: new Date("2026-07-01T12:00:00.000Z"),
|
||||
resumeFileUrl: "http://localhost:3000/api/uploads/user-1/pictures/resume.pdf",
|
||||
coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf",
|
||||
};
|
||||
@@ -71,6 +77,9 @@ beforeEach(() => {
|
||||
dbMock.insert.mockReset();
|
||||
dbMock.update.mockReset();
|
||||
dbMock.delete.mockReset();
|
||||
dbMock.execute.mockReset();
|
||||
dbMock.transaction.mockReset();
|
||||
dbMock.transaction.mockImplementation((callback) => callback(dbMock));
|
||||
resumeGetByIdMock.mockReset();
|
||||
storageDeleteMock.mockReset();
|
||||
uploadFileMock.mockReset();
|
||||
@@ -84,15 +93,22 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("applicationService.create", () => {
|
||||
it("seeds a 'created' activity event", async () => {
|
||||
it("seeds an initial stage timeline entry with the chosen date", async () => {
|
||||
const values = vi.fn(() => Promise.resolve());
|
||||
dbMock.insert.mockReturnValue({ values });
|
||||
|
||||
await applicationService.create({ userId: "user-1", company: "Stripe", role: "Engineer", status: "applied" });
|
||||
await applicationService.create({
|
||||
userId: "user-1",
|
||||
company: "Stripe",
|
||||
role: "Engineer",
|
||||
status: "applied",
|
||||
stageEnteredAt: "2026-07-10",
|
||||
} as never);
|
||||
|
||||
const [[inserted]] = values.mock.calls as unknown as [[{ activity: { type: string }[] }]];
|
||||
const [[inserted]] = values.mock.calls as unknown as [[{ activity: { type: string; stage: string; at: Date }[] }]];
|
||||
expect(inserted.activity).toHaveLength(1);
|
||||
expect(inserted.activity.at(0)?.type).toBe("created");
|
||||
expect(inserted.activity.at(0)).toMatchObject({ type: "stage", stage: "applied" });
|
||||
expect(inserted.activity.at(0)?.at.toISOString()).toBe("2026-07-10T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("checks linked resume ownership before inserting", async () => {
|
||||
@@ -118,12 +134,17 @@ describe("applicationService.update", () => {
|
||||
return set;
|
||||
};
|
||||
|
||||
it("appends a 'stage' event when the status changes", async () => {
|
||||
const appendedEvent = (activity: { values: unknown[] }) => {
|
||||
const value = activity.values.find((item) => typeof item === "string" && item.includes('"type":"stage"'));
|
||||
return JSON.parse(String(value))[0] as { type: string; stage: string };
|
||||
};
|
||||
|
||||
it("appends a typed stage timeline entry 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();
|
||||
const [[arg]] = set.mock.calls as unknown as [[{ activity: { values: unknown[] } }]];
|
||||
expect(appendedEvent(arg.activity)).toMatchObject({ type: "stage", stage: "applied" });
|
||||
});
|
||||
|
||||
it("does not rewrite activity when the status is unchanged", async () => {
|
||||
@@ -142,6 +163,221 @@ describe("applicationService.update", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applicationService timeline entries", () => {
|
||||
const captureSet = (returning = [{ ...existing }]) => {
|
||||
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve(returning) }) }));
|
||||
dbMock.update.mockReturnValue({ set });
|
||||
return set;
|
||||
};
|
||||
|
||||
it("adds dated note timeline entries", async () => {
|
||||
const set = captureSet();
|
||||
|
||||
await applicationService.addNote({
|
||||
id: "app-1",
|
||||
userId: "user-1",
|
||||
text: "Recruiter replied",
|
||||
date: "2026-07-12",
|
||||
} as never);
|
||||
|
||||
const [[arg]] = set.mock.calls as unknown as [[{ activity: { values: unknown[] } }]];
|
||||
const value = arg.activity.values.find((item) => typeof item === "string" && item.includes('"type":"note"'));
|
||||
const [entry] = JSON.parse(String(value)) as [{ type: string; text: string; at: string }];
|
||||
expect(entry).toMatchObject({ type: "note", text: "Recruiter replied" });
|
||||
expect(new Date(entry.at).toISOString()).toBe("2026-07-12T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects invalid calendar dates instead of letting Date overflow", async () => {
|
||||
await expect(
|
||||
applicationService.addNote({
|
||||
id: "app-1",
|
||||
userId: "user-1",
|
||||
text: "Impossible date",
|
||||
date: "2026-99-99",
|
||||
} as never),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("updates note text and timeline dates", async () => {
|
||||
const activity = [
|
||||
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T09:30:00.000Z") },
|
||||
{ id: "note-1", type: "note" as const, text: "Old note", at: new Date("2026-07-02T15:45:00.000Z") },
|
||||
];
|
||||
setSelectResults([{ ...existing, activity }]);
|
||||
const set = captureSet();
|
||||
|
||||
await (
|
||||
applicationService as unknown as {
|
||||
updateTimelineEntry: (input: {
|
||||
id: string;
|
||||
userId: string;
|
||||
entryId: string;
|
||||
date?: string;
|
||||
text?: string;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
).updateTimelineEntry({
|
||||
id: "app-1",
|
||||
userId: "user-1",
|
||||
entryId: "note-1",
|
||||
date: "2026-07-10",
|
||||
text: "Updated note",
|
||||
});
|
||||
|
||||
const [[arg]] = set.mock.calls as unknown as [[{ activity: typeof activity }]];
|
||||
expect(arg.activity.find((entry) => entry.id === "note-1")).toMatchObject({
|
||||
text: "Updated note",
|
||||
at: new Date("2026-07-10T15:45:00.000Z"),
|
||||
});
|
||||
expect(dbMock.transaction).toHaveBeenCalled();
|
||||
expect(dbMock.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes JSONB date strings when editing timeline dates", async () => {
|
||||
const activity = [
|
||||
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: "2026-07-01T09:30:00.000Z" },
|
||||
];
|
||||
setSelectResults([{ ...existing, activity }]);
|
||||
const set = captureSet();
|
||||
|
||||
await (
|
||||
applicationService as unknown as {
|
||||
updateTimelineEntry: (input: { id: string; userId: string; entryId: string; date: string }) => Promise<unknown>;
|
||||
}
|
||||
).updateTimelineEntry({
|
||||
id: "app-1",
|
||||
userId: "user-1",
|
||||
entryId: "stage-1",
|
||||
date: "2026-07-05",
|
||||
});
|
||||
|
||||
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string; at: Date }[] }]];
|
||||
expect(arg.activity.find((entry) => entry.id === "stage-1")?.at.toISOString()).toBe("2026-07-05T09:30:00.000Z");
|
||||
});
|
||||
|
||||
it("allows notes to be newer than the current-stage anchor", async () => {
|
||||
const activity = [
|
||||
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T12:00:00.000Z") },
|
||||
{ id: "note-1", type: "note" as const, text: "Followed up", at: new Date("2026-07-10T12:00:00.000Z") },
|
||||
];
|
||||
setSelectResults([{ ...existing, activity }]);
|
||||
const set = captureSet();
|
||||
|
||||
await (
|
||||
applicationService as unknown as {
|
||||
updateTimelineEntry: (input: { id: string; userId: string; entryId: string; date: string }) => Promise<unknown>;
|
||||
}
|
||||
).updateTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-1", date: "2026-07-02" });
|
||||
|
||||
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string; at: Date }[] }]];
|
||||
expect(arg.activity.find((entry) => entry.id === "stage-1")?.at.toISOString()).toBe("2026-07-02T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("blocks moving the current-stage anchor older than another stage", async () => {
|
||||
setSelectResults([
|
||||
{
|
||||
...existing,
|
||||
status: "screening",
|
||||
activity: [
|
||||
{
|
||||
id: "stage-1",
|
||||
type: "stage" as const,
|
||||
stage: "applied" as const,
|
||||
at: new Date("2026-07-03T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "stage-2",
|
||||
type: "stage" as const,
|
||||
stage: "screening" as const,
|
||||
at: new Date("2026-07-04T12:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
(
|
||||
applicationService as unknown as {
|
||||
updateTimelineEntry: (input: {
|
||||
id: string;
|
||||
userId: string;
|
||||
entryId: string;
|
||||
date: string;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
).updateTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2", date: "2026-07-01" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("blocks deleting the current-stage anchor", async () => {
|
||||
setSelectResults([
|
||||
{
|
||||
...existing,
|
||||
status: "screening",
|
||||
activity: [
|
||||
{
|
||||
id: "stage-1",
|
||||
type: "stage" as const,
|
||||
stage: "applied" as const,
|
||||
at: new Date("2026-07-01T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "stage-2",
|
||||
type: "stage" as const,
|
||||
stage: "screening" as const,
|
||||
at: new Date("2026-07-03T12:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
(
|
||||
applicationService as unknown as {
|
||||
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => Promise<unknown>;
|
||||
}
|
||||
).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(dbMock.transaction).toHaveBeenCalled();
|
||||
expect(dbMock.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes older stage entries", async () => {
|
||||
setSelectResults([
|
||||
{
|
||||
...existing,
|
||||
status: "screening",
|
||||
activity: [
|
||||
{
|
||||
id: "stage-1",
|
||||
type: "stage" as const,
|
||||
stage: "applied" as const,
|
||||
at: new Date("2026-07-01T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "stage-2",
|
||||
type: "stage" as const,
|
||||
stage: "screening" as const,
|
||||
at: new Date("2026-07-03T12:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const set = captureSet();
|
||||
|
||||
await (
|
||||
applicationService as unknown as {
|
||||
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => Promise<unknown>;
|
||||
}
|
||||
).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-1" });
|
||||
|
||||
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string }[] }]];
|
||||
expect(arg.activity).toEqual([
|
||||
{ id: "stage-2", type: "stage", stage: "screening", at: new Date("2026-07-03T12:00:00.000Z") },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applicationService.delete", () => {
|
||||
it("deletes owned uploaded attachments after deleting the application", async () => {
|
||||
dbMock.delete.mockReturnValue({
|
||||
|
||||
@@ -1,18 +1,87 @@
|
||||
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
|
||||
import type {
|
||||
AiMetadata,
|
||||
ApplicationStatus,
|
||||
ApplicationTimelineEntry,
|
||||
Contact,
|
||||
} from "@reactive-resume/schema/applications/data";
|
||||
import type { ApplicationDocumentKind } from "../../dto/application";
|
||||
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, uploadFile } from "../storage/service";
|
||||
|
||||
const stageLabel = (status: ApplicationStatus) => STAGES.find((s) => s.value === status)?.label ?? status;
|
||||
function timelineDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function activityEvent(type: ActivityEvent["type"], text: string): ActivityEvent {
|
||||
return { id: generateId(), type, text, at: new Date() };
|
||||
function atFromDateString(date: string, existing?: Date | string): Date {
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
if (!year || !month || !day) throw new ORPCError("BAD_REQUEST", { message: "Date must use YYYY-MM-DD format." });
|
||||
|
||||
const existingDate = existing ? timelineDate(existing) : undefined;
|
||||
|
||||
const parsed = new Date(
|
||||
Date.UTC(
|
||||
year,
|
||||
month - 1,
|
||||
day,
|
||||
existingDate?.getUTCHours() ?? 12,
|
||||
existingDate?.getUTCMinutes() ?? 0,
|
||||
existingDate?.getUTCSeconds() ?? 0,
|
||||
existingDate?.getUTCMilliseconds() ?? 0,
|
||||
),
|
||||
);
|
||||
if (timelineDay(parsed) !== date) throw new ORPCError("BAD_REQUEST", { message: "Date must use YYYY-MM-DD format." });
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function stageEntry(stage: ApplicationStatus, date?: string): ApplicationTimelineEntry {
|
||||
return { id: generateId(), type: "stage", stage, at: date ? atFromDateString(date) : new Date() };
|
||||
}
|
||||
|
||||
function noteEntry(text: string, date?: string): ApplicationTimelineEntry {
|
||||
return { id: generateId(), type: "note", text, at: date ? atFromDateString(date) : new Date() };
|
||||
}
|
||||
|
||||
function byNewest(a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) {
|
||||
return new Date(b.at).getTime() - new Date(a.at).getTime();
|
||||
}
|
||||
|
||||
function timelineDay(value: Date | string) {
|
||||
return timelineDate(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function sortTimeline(activity: ApplicationTimelineEntry[]): ApplicationTimelineEntry[] {
|
||||
return [...activity].sort(byNewest);
|
||||
}
|
||||
|
||||
function currentStageAnchor(activity: ApplicationTimelineEntry[], status: ApplicationStatus) {
|
||||
return sortTimeline(activity).find((entry) => entry.type === "stage" && entry.stage === status);
|
||||
}
|
||||
|
||||
function assertCurrentStageAnchorLatest(activity: ApplicationTimelineEntry[], status: ApplicationStatus) {
|
||||
const anchor = currentStageAnchor(activity, status);
|
||||
if (!anchor)
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Application timeline is missing its current stage entry." });
|
||||
|
||||
const anchorDay = timelineDay(anchor.at);
|
||||
const newerStage = activity.some((entry) => entry.type === "stage" && timelineDay(entry.at) > anchorDay);
|
||||
if (newerStage) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Current stage date cannot be older than another stage entry.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appliedAtFromTimeline(activity: ApplicationTimelineEntry[], fallback: Date): Date {
|
||||
const sorted = sortTimeline(activity);
|
||||
const applied = sorted.find((entry) => entry.type === "stage" && entry.stage === "applied");
|
||||
const fallbackEntry = sorted.at(-1);
|
||||
return applied ? timelineDate(applied.at) : fallbackEntry ? timelineDate(fallbackEntry.at) : fallback;
|
||||
}
|
||||
|
||||
// Editable fields shared by create/update. Kept explicit so Drizzle's typed insert/update
|
||||
@@ -124,9 +193,9 @@ function documentFields(kind: ApplicationDocumentKind) {
|
||||
} as const);
|
||||
}
|
||||
|
||||
const stripUserId = <T extends { userId: string }>(row: T) => {
|
||||
const stripUserId = <T extends { userId: string; activity?: ApplicationTimelineEntry[] }>(row: T) => {
|
||||
const { userId: _userId, ...rest } = row;
|
||||
return rest;
|
||||
return rest.activity ? { ...rest, activity: sortTimeline(rest.activity) } : rest;
|
||||
};
|
||||
|
||||
export const applicationService = {
|
||||
@@ -151,18 +220,27 @@ export const applicationService = {
|
||||
},
|
||||
|
||||
create: async (
|
||||
input: EditableFields & { userId: string; company: string; role: string; status?: ApplicationStatus | undefined },
|
||||
input: EditableFields & {
|
||||
userId: string;
|
||||
company: string;
|
||||
role: string;
|
||||
status?: ApplicationStatus | undefined;
|
||||
stageEnteredAt?: string | undefined;
|
||||
},
|
||||
) => {
|
||||
const { userId, status, ...fields } = input;
|
||||
const { userId, status, stageEnteredAt, ...fields } = input;
|
||||
const id = generateId();
|
||||
const initialStatus = status ?? "saved";
|
||||
const activity = [stageEntry(initialStatus, stageEnteredAt)];
|
||||
|
||||
await assertOwnedResume(userId, fields.resumeId);
|
||||
|
||||
await db.insert(schema.application).values({
|
||||
id,
|
||||
userId,
|
||||
status: status ?? "saved",
|
||||
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
|
||||
status: initialStatus,
|
||||
activity,
|
||||
appliedAt: appliedAtFromTimeline(activity, new Date()),
|
||||
...fields,
|
||||
});
|
||||
|
||||
@@ -171,7 +249,12 @@ export const applicationService = {
|
||||
|
||||
importMany: async (input: {
|
||||
userId: string;
|
||||
items: (EditableFields & { company: string; role: string; status?: ApplicationStatus | undefined })[];
|
||||
items: (EditableFields & {
|
||||
company: string;
|
||||
role: string;
|
||||
status?: ApplicationStatus | undefined;
|
||||
stageEnteredAt?: string | undefined;
|
||||
})[];
|
||||
}) => {
|
||||
if (input.items.length === 0) return { imported: 0 };
|
||||
|
||||
@@ -180,13 +263,18 @@ export const applicationService = {
|
||||
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 values = input.items.map(({ status, stageEnteredAt, ...fields }) => {
|
||||
const initialStatus = status ?? ("saved" as ApplicationStatus);
|
||||
const activity = [stageEntry(initialStatus, stageEnteredAt)];
|
||||
return {
|
||||
id: generateId(),
|
||||
userId: input.userId,
|
||||
status: initialStatus,
|
||||
activity,
|
||||
appliedAt: appliedAtFromTimeline(activity, new Date()),
|
||||
...fields,
|
||||
};
|
||||
});
|
||||
|
||||
const rows = await db.insert(schema.application).values(values).returning({ id: schema.application.id });
|
||||
return { imported: rows.length };
|
||||
@@ -205,12 +293,19 @@ export const applicationService = {
|
||||
const { id, userId, status, archived, ...fields } = input;
|
||||
await assertOwnedResume(userId, fields.resumeId);
|
||||
|
||||
const statusEntry = status !== undefined ? stageEntry(status) : undefined;
|
||||
// Append in SQL so concurrent notes/stage events are not overwritten by a stale array.
|
||||
const activityExpr =
|
||||
status !== undefined
|
||||
statusEntry !== 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`
|
||||
then ${schema.application.activity} || ${JSON.stringify([statusEntry])}::jsonb
|
||||
else ${schema.application.activity} end`
|
||||
: undefined;
|
||||
const appliedAtExpr =
|
||||
statusEntry !== undefined && status === "applied"
|
||||
? sql`case when ${schema.application.status} <> ${status}
|
||||
then ${statusEntry.at}
|
||||
else ${schema.application.appliedAt} end`
|
||||
: undefined;
|
||||
|
||||
const [updated] = await db
|
||||
@@ -218,6 +313,7 @@ export const applicationService = {
|
||||
.set({
|
||||
...fields,
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(appliedAtExpr ? { appliedAt: appliedAtExpr } : {}),
|
||||
...(archived !== undefined ? { archived } : {}),
|
||||
...(activityExpr ? { activity: activityExpr } : {}),
|
||||
})
|
||||
@@ -313,10 +409,10 @@ export const applicationService = {
|
||||
return stripUserId(updated);
|
||||
},
|
||||
|
||||
addNote: async (input: { id: string; userId: string; text: string }) => {
|
||||
addNote: async (input: { id: string; userId: string; text: string; date?: string | undefined }) => {
|
||||
// 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 event = noteEntry(input.text, input.date);
|
||||
const [updated] = await db
|
||||
.update(schema.application)
|
||||
.set({ activity: sql`${schema.application.activity} || ${JSON.stringify([event])}::jsonb` })
|
||||
@@ -327,6 +423,96 @@ export const applicationService = {
|
||||
return stripUserId(updated);
|
||||
},
|
||||
|
||||
updateTimelineEntry: async (input: {
|
||||
id: string;
|
||||
userId: string;
|
||||
entryId: string;
|
||||
date?: string | undefined;
|
||||
text?: string | undefined;
|
||||
}) => {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select 1 from ${schema.application}
|
||||
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
|
||||
for update
|
||||
`);
|
||||
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(schema.application)
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const activity = existing.activity.map((entry) => {
|
||||
if (entry.id !== input.entryId) return entry;
|
||||
|
||||
if (entry.type === "stage" && input.text !== undefined) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Stage timeline text is derived and cannot be edited." });
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
...(input.date !== undefined ? { at: atFromDateString(input.date, entry.at) } : {}),
|
||||
...(entry.type === "note" && input.text !== undefined ? { text: input.text } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
if (!activity.some((entry) => entry.id === input.entryId)) throw new ORPCError("NOT_FOUND");
|
||||
assertCurrentStageAnchorLatest(activity, existing.status);
|
||||
|
||||
const [updated] = await tx
|
||||
.update(schema.application)
|
||||
.set({
|
||||
activity,
|
||||
appliedAt: appliedAtFromTimeline(activity, existing.appliedAt),
|
||||
})
|
||||
.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);
|
||||
});
|
||||
},
|
||||
|
||||
deleteTimelineEntry: async (input: { id: string; userId: string; entryId: string }) => {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select 1 from ${schema.application}
|
||||
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
|
||||
for update
|
||||
`);
|
||||
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(schema.application)
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const entry = existing.activity.find((item) => item.id === input.entryId);
|
||||
if (!entry) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const anchor = currentStageAnchor(existing.activity, existing.status);
|
||||
if (entry.type === "stage" && anchor?.id === entry.id) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "The current stage timeline entry cannot be deleted." });
|
||||
}
|
||||
|
||||
const activity = existing.activity.filter((item) => item.id !== input.entryId);
|
||||
assertCurrentStageAnchorLatest(activity, existing.status);
|
||||
|
||||
const [updated] = await tx
|
||||
.update(schema.application)
|
||||
.set({
|
||||
activity,
|
||||
appliedAt: appliedAtFromTimeline(activity, existing.appliedAt),
|
||||
})
|
||||
.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
|
||||
@@ -359,17 +545,25 @@ export const applicationService = {
|
||||
|
||||
// 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 statusEntry = input.status !== undefined ? stageEntry(input.status) : undefined;
|
||||
const activityExpr =
|
||||
input.status !== undefined
|
||||
statusEntry !== 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`
|
||||
then ${schema.application.activity} || ${JSON.stringify([statusEntry])}::jsonb
|
||||
else ${schema.application.activity} end`
|
||||
: undefined;
|
||||
const appliedAtExpr =
|
||||
statusEntry !== undefined && input.status === "applied"
|
||||
? sql`case when ${schema.application.status} <> ${input.status}
|
||||
then ${statusEntry.at}
|
||||
else ${schema.application.appliedAt} end`
|
||||
: undefined;
|
||||
|
||||
const rows = await db
|
||||
.update(schema.application)
|
||||
.set({
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(appliedAtExpr ? { appliedAt: appliedAtExpr } : {}),
|
||||
...(activityExpr ? { activity: activityExpr } : {}),
|
||||
...(input.archived !== undefined ? { archived: input.archived } : {}),
|
||||
...(tagsExpr ? { tags: tagsExpr } : {}),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
|
||||
import type {
|
||||
AiMetadata,
|
||||
ApplicationStatus,
|
||||
ApplicationTimelineEntry,
|
||||
Contact,
|
||||
} from "@reactive-resume/schema/applications/data";
|
||||
import * as pg from "drizzle-orm/pg-core";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { user } from "./auth";
|
||||
@@ -47,7 +52,7 @@ export const application = pg.pgTable(
|
||||
followUpAt: pg.timestamp("follow_up_at", { withTimezone: true }),
|
||||
followUpNote: pg.text("follow_up_note"),
|
||||
contacts: pg.jsonb("contacts").notNull().$type<Contact[]>().default([]),
|
||||
activity: pg.jsonb("activity").notNull().$type<ActivityEvent[]>().default([]),
|
||||
activity: pg.jsonb("activity").notNull().$type<ApplicationTimelineEntry[]>().default([]),
|
||||
appliedAt: pg.timestamp("applied_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: pg
|
||||
|
||||
@@ -21,6 +21,8 @@ export const MCP_TOOL_NAME = {
|
||||
createApplication: "create_application",
|
||||
updateApplication: "update_application",
|
||||
addApplicationNote: "add_application_note",
|
||||
updateApplicationTimelineEntry: "update_application_timeline_entry",
|
||||
deleteApplicationTimelineEntry: "delete_application_timeline_entry",
|
||||
deleteApplication: "delete_application",
|
||||
bulkUpdateApplications: "bulk_update_applications",
|
||||
bulkDeleteApplications: "bulk_delete_applications",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> =
|
||||
[MCP_TOOL_NAME.createApplication]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.updateApplication]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.addApplicationNote]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.updateApplicationTimelineEntry]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.deleteApplicationTimelineEntry]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.deleteApplication]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.bulkUpdateApplications]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.bulkDeleteApplications]: WRITE_DESTRUCTIVE,
|
||||
|
||||
@@ -16,7 +16,9 @@ const applicationIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(`Application ID. Use \`${T.listApplications}\` to find valid IDs.`);
|
||||
const applicationTimelineEntryIdSchema = z.string().min(1).describe("Timeline entry ID from an application response.");
|
||||
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
|
||||
const timelineDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must use YYYY-MM-DD format.");
|
||||
const httpUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -67,6 +69,7 @@ const createApplicationSchema = z
|
||||
...applicationMutableFieldsSchema,
|
||||
company: z.string().min(1).describe("Company name."),
|
||||
role: z.string().min(1).describe("Role or job title."),
|
||||
stageEnteredAt: timelineDateSchema.optional().describe("Initial stage date in YYYY-MM-DD format."),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -343,9 +346,32 @@ export const TOOL_META = {
|
||||
[T.addApplicationNote]: {
|
||||
title: "Add Application Note",
|
||||
description: "Append a free-text note to an application's timeline.",
|
||||
inputSchema: z.object({ id: applicationIdSchema, text: z.string().min(1) }),
|
||||
inputSchema: z.object({
|
||||
id: applicationIdSchema,
|
||||
text: z.string().min(1),
|
||||
date: timelineDateSchema.optional().describe("Optional note date in YYYY-MM-DD format."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.addApplicationNote],
|
||||
},
|
||||
[T.updateApplicationTimelineEntry]: {
|
||||
title: "Update Application Timeline Entry",
|
||||
description: "Update a timeline entry date, or note text for note entries.",
|
||||
inputSchema: z
|
||||
.object({
|
||||
id: applicationIdSchema,
|
||||
entryId: applicationTimelineEntryIdSchema,
|
||||
date: timelineDateSchema.optional().describe("Replacement row date in YYYY-MM-DD format."),
|
||||
text: z.string().min(1).optional().describe("Replacement note text. Only note entries can change text."),
|
||||
})
|
||||
.refine((value) => value.date !== undefined || value.text !== undefined, "Provide date or text to update."),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateApplicationTimelineEntry],
|
||||
},
|
||||
[T.deleteApplicationTimelineEntry]: {
|
||||
title: "Delete Application Timeline Entry",
|
||||
description: "Delete a note or older stage entry. The current stage entry cannot be deleted.",
|
||||
inputSchema: z.object({ id: applicationIdSchema, entryId: applicationTimelineEntryIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.deleteApplicationTimelineEntry],
|
||||
},
|
||||
[T.deleteApplication]: {
|
||||
title: "Delete Application",
|
||||
description: "Permanently delete one job application and its owned uploaded documents.",
|
||||
|
||||
@@ -71,6 +71,8 @@ const clientMock = {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addNote: vi.fn(),
|
||||
updateTimelineEntry: vi.fn(),
|
||||
deleteTimelineEntry: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
bulkUpdate: vi.fn(),
|
||||
bulkDelete: vi.fn(),
|
||||
@@ -185,6 +187,51 @@ describe("registerTools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds dated application notes through the router client", async () => {
|
||||
clientMock.applications.addNote.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerTools(server as never, clientMock as never, new Headers());
|
||||
|
||||
const tool = registered.find((item) => item.name === "add_application_note")!;
|
||||
await tool.handler({ id: "app-1", text: "Recruiter replied", date: "2026-07-12" });
|
||||
|
||||
expect(clientMock.applications.addNote).toHaveBeenCalledWith({
|
||||
id: "app-1",
|
||||
text: "Recruiter replied",
|
||||
date: "2026-07-12",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates application timeline entries through the router client", async () => {
|
||||
clientMock.applications.updateTimelineEntry.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerTools(server as never, clientMock as never, new Headers());
|
||||
|
||||
const tool = registered.find((item) => item.name === "update_application_timeline_entry")!;
|
||||
await tool.handler({ id: "app-1", entryId: "entry-1", date: "2026-07-13", text: "Updated note" });
|
||||
|
||||
expect(clientMock.applications.updateTimelineEntry).toHaveBeenCalledWith({
|
||||
id: "app-1",
|
||||
entryId: "entry-1",
|
||||
date: "2026-07-13",
|
||||
text: "Updated note",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes application timeline entries through the router client", async () => {
|
||||
clientMock.applications.deleteTimelineEntry.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerTools(server as never, clientMock as never, new Headers());
|
||||
|
||||
const tool = registered.find((item) => item.name === "delete_application_timeline_entry")!;
|
||||
await tool.handler({ id: "app-1", entryId: "entry-1" });
|
||||
|
||||
expect(clientMock.applications.deleteTimelineEntry).toHaveBeenCalledWith({
|
||||
id: "app-1",
|
||||
entryId: "entry-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("imports applications with followUpAt coerced to Date and null preserved", async () => {
|
||||
clientMock.applications.import.mockResolvedValueOnce({ imported: 2 });
|
||||
const { server, registered } = makeFakeServer();
|
||||
|
||||
@@ -410,11 +410,33 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
server.registerTool(
|
||||
T.addApplicationNote,
|
||||
TOOL_META[T.addApplicationNote],
|
||||
withErrorHandling("adding application note", async ({ id, text: noteText }: { id: string; text: string }) => {
|
||||
return json(await client.applications.addNote({ id, text: noteText }));
|
||||
withErrorHandling(
|
||||
"adding application note",
|
||||
async ({ id, text: noteText, date }: { id: string; text: string; date?: string | undefined }) => {
|
||||
return json(await client.applications.addNote({ id, text: noteText, date }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
T.updateApplicationTimelineEntry,
|
||||
TOOL_META[T.updateApplicationTimelineEntry],
|
||||
withErrorHandling("updating application timeline entry", async (params) => {
|
||||
return json(await client.applications.updateTimelineEntry(params as never));
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
T.deleteApplicationTimelineEntry,
|
||||
TOOL_META[T.deleteApplicationTimelineEntry],
|
||||
withErrorHandling(
|
||||
"deleting application timeline entry",
|
||||
async ({ id, entryId }: { id: string; entryId: string }) => {
|
||||
return json(await client.applications.deleteTimelineEntry({ id, entryId }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
T.deleteApplication,
|
||||
TOOL_META[T.deleteApplication],
|
||||
|
||||
@@ -26,16 +26,24 @@ export const contactSchema = z.object({
|
||||
|
||||
export type Contact = z.infer<typeof contactSchema>;
|
||||
|
||||
// Timeline events: `stage` entries are auto-appended when an application moves; `note`
|
||||
// entries are added manually from the detail panel.
|
||||
export const activityEventSchema = z.object({
|
||||
const timelineBaseSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["stage", "note", "created"]),
|
||||
text: z.string().trim().min(1),
|
||||
at: z.coerce.date(),
|
||||
});
|
||||
|
||||
export type ActivityEvent = z.infer<typeof activityEventSchema>;
|
||||
export const applicationTimelineEntrySchema = z.discriminatedUnion("type", [
|
||||
timelineBaseSchema.extend({
|
||||
type: z.literal("stage"),
|
||||
stage: applicationStatusSchema,
|
||||
}),
|
||||
timelineBaseSchema.extend({
|
||||
type: z.literal("note"),
|
||||
text: z.string().trim().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type ApplicationTimelineEntry = z.infer<typeof applicationTimelineEntrySchema>;
|
||||
export type ActivityEvent = ApplicationTimelineEntry;
|
||||
|
||||
// Reserved for AI enrichment output (autofill / match-score). Free-form so the shape can
|
||||
// evolve without a migration. See the AI roadmap in the applications feature.
|
||||
|
||||
@@ -39,9 +39,9 @@ test("adds an application and logs stage changes and notes", async ({ authPage:
|
||||
await expect(detail.getByText(company)).toBeVisible();
|
||||
await detail.getByRole("button", { name: "Move to Applied" }).click();
|
||||
await expect(detail.getByRole("button", { name: "Move to Screening" })).toBeVisible();
|
||||
await expect(detail.getByText("Moved to Applied")).toBeVisible();
|
||||
await expect(detail.locator('[data-timeline-entry="stage"][data-stage="applied"]')).toBeVisible();
|
||||
|
||||
await detail.getByPlaceholder("Add a note or log activity…").fill(note);
|
||||
await detail.locator("[data-timeline-note-input]").fill(note);
|
||||
await detail.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(detail.getByText(note)).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user