feat(workspace): interview and follow-up workflow
CI and Deploy / test (push) Failing after 3m54s
CI and Deploy / deploy (push) Has been skipped

Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase.

Interview preparation gets a durable, user-owned store. There were already two
per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are
caches that regenerate when their context signature changes — anything a user
typed into them would eventually be overwritten. InterviewPrepItem is the side
nothing regenerates, covering company research, technical notes, behavioural
answers, STAR examples and the user's own questions in one table, because those
categories differ only by label and adding one must not need a migration. Each
item records whether the user wrote it or accepted a suggestion, and an
IsPrepared flag makes the section double as the preparation checklist.

Generation stays in the existing AiWorkspaceService "interview" module, appended
to AiInteraction as before. A suggestion is history until the user adds it as a
prep item; opening the section generates nothing.

Follow-up reuses what exists rather than adding a tracker. The date is
JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted
service already act on, so reminders keep working with no new wiring. The task
stays an ApplicationChecklistItem in the follow-up category — the section counts
open tasks without owning them. The record is a FollowUpSet JobEvent, the same
type the rest of the app emits.

Communication is untouched: Correspondence already owns recruiter contacts,
history and notes, and the workspace already mounted it.

The timeline interpreter learned five more types — InterviewScheduled,
InterviewCompleted and OfferReceived as milestones, FollowUpCreated and
FollowUpCompleted as routine, deliberately outside the milestone spine so it
stays a summary of what actually happened. JobEvent remains the history source.

InterviewPrepItems is reconciler-owned with a no-op migration, guarded on
JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary
key, varchar owner and title, tinyint flag, datetime(6), composite index inside
the key limit.

371 backend tests, 128 frontend tests, Release build and the production build all
pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-19 17:01:33 +02:00
parent 4759f1f610
commit 3d74baef78
17 changed files with 3788 additions and 3 deletions
@@ -234,6 +234,61 @@ export const applicationAssetsApi = {
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
};
// Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come
// from the existing /ai routes and only land here once accepted.
export type InterviewPrepItem = {
id: number;
category: string;
title: string;
content: string | null;
source: string;
isPrepared: boolean;
sortOrder: number;
updatedAtUtc: string;
};
export type InterviewPrepGroup = { category: string; label: string; items: InterviewPrepItem[] };
export type InterviewPrepBoard = {
groups: InterviewPrepGroup[];
total: number;
prepared: number;
percent: number;
isInterviewStage: boolean;
aiSuggestionCount: number;
};
export type FollowUp = {
followUpAt: string | null;
nextAction: string | null;
responseReceived: boolean;
openFollowUpTasks: number;
};
export const INTERVIEW_PREP_CATEGORIES: { key: string; label: string }[] = [
{ key: "company-research", label: "Company research" },
{ key: "technical", label: "Technical preparation" },
{ key: "behavioural", label: "Behavioural questions" },
{ key: "star", label: "STAR examples" },
{ key: "question", label: "Questions to ask them" },
{ key: "note", label: "Notes" },
];
export const interviewPrepApi = {
get: (jobId: number) =>
api.get<InterviewPrepBoard>(`/jobapplications/${jobId}/interview-prep`).then((r) => r.data),
add: (jobId: number, body: { category?: string; title: string; content?: string; source?: string }) =>
api.post<InterviewPrepItem>(`/jobapplications/${jobId}/interview-prep`, body).then((r) => r.data),
update: (jobId: number, itemId: number, body: Partial<Pick<InterviewPrepItem, "title" | "content" | "category" | "isPrepared">>) =>
api.patch<InterviewPrepItem>(`/jobapplications/${jobId}/interview-prep/${itemId}`, body).then((r) => r.data),
remove: (jobId: number, itemId: number) =>
api.delete(`/jobapplications/${jobId}/interview-prep/${itemId}`).then(() => undefined),
followUp: (jobId: number) =>
api.get<FollowUp>(`/jobapplications/${jobId}/follow-up`).then((r) => r.data),
setFollowUp: (jobId: number, followUpAt: string | null, nextAction?: string | null) =>
api.put<FollowUp>(`/jobapplications/${jobId}/follow-up`, { followUpAt, nextAction }).then((r) => r.data),
};
export const applicationChecklistApi = {
get: (jobId: number) =>
api.get<Checklist>(`/jobapplications/${jobId}/checklist`).then((r) => r.data),
@@ -0,0 +1,352 @@
import React, { useCallback, useEffect, useState } from "react";
import {
Alert, Box, Button, Checkbox, Chip, Divider, IconButton, LinearProgress, MenuItem, Paper,
Skeleton, Stack, TextField, Tooltip, Typography,
} from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { getApiErrorMessage } from "../api";
import {
FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi,
} from "../applicationWorkspace";
// Phase 5.5 — Interview preparation and follow-up.
//
// The prep content is the user's: this component never generates anything. AI suggestions live in the
// AI panel below and only become prep items when the user adds them.
// docs/architecture/application-workspace.md.
function Shell({ title, subtitle, loading, error, children }: {
title: string;
subtitle?: string;
loading: boolean;
error: string | null;
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
children
)}
</Paper>
);
}
export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
const [board, setBoard] = useState<InterviewPrepBoard | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [title, setTitle] = useState("");
const [category, setCategory] = useState(INTERVIEW_PREP_CATEGORIES[0].key);
const load = useCallback(async () => {
setLoading(true);
try {
setBoard(await interviewPrepApi.get(jobId));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, "Could not load interview preparation."));
} finally {
setLoading(false);
}
}, [jobId]);
useEffect(() => {
load();
}, [load]);
const mutate = async (run: () => Promise<unknown>) => {
setBusy(true);
try {
await run();
await load();
} catch (err) {
setError(getApiErrorMessage(err, "Could not update interview preparation."));
} finally {
setBusy(false);
}
};
const add = (e: React.FormEvent) => {
e.preventDefault();
const value = title.trim();
if (!value) return;
setTitle("");
return mutate(() => interviewPrepApi.add(jobId, { category, title: value }));
};
return (
<Stack spacing={2}>
<Shell
title="Interview preparation"
subtitle="Your own research, answers and questions. Nothing here is generated or overwritten."
loading={loading}
error={error}
>
<Stack spacing={2}>
{board && !board.isInterviewStage && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
This application has not reached an interview stage yet. Preparing early is fine.
</Alert>
)}
{board && board.total > 0 && (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 0.75 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
Preparation progress
</Typography>
<Typography variant="body2" color="text.secondary">
{board.prepared} of {board.total} ready
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={board.percent}
aria-label="Interview preparation progress"
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
)}
{board && board.total === 0 ? (
<Typography variant="body2" color="text.secondary">
Nothing prepared yet. Add a question you expect, a company fact worth knowing, or a STAR
example you want ready.
</Typography>
) : (
(board?.groups ?? []).map((group) => (
<Box key={group.category}>
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
{group.label}
</Typography>
<Stack sx={{ mt: 0.5 }}>
{group.items.map((item) => (
<PrepRow
key={item.id}
jobId={jobId}
item={item}
busy={busy}
onChanged={load}
onError={setError}
/>
))}
</Stack>
</Box>
))
)}
<Box component="form" onSubmit={add}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
select
size="small"
label="Category"
value={category}
disabled={busy}
onChange={(e) => setCategory(e.target.value)}
sx={{ minWidth: { sm: 200 } }}
>
{INTERVIEW_PREP_CATEGORIES.map((c) => (
<MenuItem key={c.key} value={c.key}>{c.label}</MenuItem>
))}
</TextField>
<TextField
fullWidth
size="small"
label="Add a question, topic or note"
value={title}
disabled={busy}
onChange={(e) => setTitle(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !title.trim()}>Add</Button>
</Stack>
</Box>
</Stack>
</Shell>
<ApplicationFollowUp jobId={jobId} />
</Stack>
);
}
// One prep entry. The answer is a local draft until saved, so a background reload never eats typing.
function PrepRow({ jobId, item, busy, onChanged, onError }: {
jobId: number;
item: InterviewPrepItem;
busy: boolean;
onChanged: () => void;
onError: (message: string) => void;
}) {
const [draft, setDraft] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const content = draft ?? item.content ?? "";
const dirty = draft !== null && draft !== (item.content ?? "");
const run = async (fn: () => Promise<unknown>) => {
setSaving(true);
try {
await fn();
setDraft(null);
onChanged();
} catch (err) {
onError(getApiErrorMessage(err, "Could not save this answer."));
} finally {
setSaving(false);
}
};
return (
<Box sx={{ py: 1, borderBottom: "1px solid", borderColor: "divider" }}>
<Stack direction="row" alignItems="flex-start" spacing={1}>
<Checkbox
size="small"
checked={item.isPrepared}
disabled={busy || saving}
inputProps={{ "aria-label": `Ready: ${item.title}` }}
onChange={() => run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))}
sx={{ mt: -0.5 }}
/>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{item.title}</Typography>
{item.source === "ai" && (
<Chip size="small" label="From AI" variant="outlined" />
)}
</Stack>
<TextField
multiline
minRows={2}
fullWidth
size="small"
placeholder="Your answer, in your own words."
value={content}
disabled={busy || saving}
onChange={(e) => setDraft(e.target.value)}
sx={{ mt: 0.75 }}
/>
{dirty && (
<Stack direction="row" spacing={1} sx={{ mt: 0.75 }}>
<Button
size="small"
variant="contained"
disabled={saving}
onClick={() => run(() => interviewPrepApi.update(jobId, item.id, { content }))}
>
Save answer
</Button>
<Button size="small" disabled={saving} onClick={() => setDraft(null)}>Discard</Button>
</Stack>
)}
</Box>
<Tooltip title="Delete">
<span>
<IconButton
size="small"
disabled={busy || saving}
aria-label={`Delete: ${item.title}`}
onClick={() => run(() => interviewPrepApi.remove(jobId, item.id))}
>
<DeleteOutlineIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Box>
);
}
export function ApplicationFollowUp({ jobId }: { jobId: number }) {
const [data, setData] = useState<FollowUp | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [date, setDate] = useState<string>("");
const [action, setAction] = useState<string>("");
const load = useCallback(async () => {
setLoading(true);
try {
const result = await interviewPrepApi.followUp(jobId);
setData(result);
setDate(result.followUpAt ? result.followUpAt.slice(0, 10) : "");
setAction(result.nextAction ?? "");
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, "Could not load follow-up."));
} finally {
setLoading(false);
}
}, [jobId]);
useEffect(() => {
load();
}, [load]);
const save = async () => {
setBusy(true);
try {
const result = await interviewPrepApi.setFollowUp(jobId, date || null, action || null);
setData(result);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, "Could not save the follow-up."));
} finally {
setBusy(false);
}
};
return (
<Shell
title="Follow-up"
subtitle="The same date the reminder service already uses. Follow-up tasks live in the checklist."
loading={loading}
error={error}
>
<Stack spacing={2}>
{data && data.openFollowUpTasks > 0 && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
{data.openFollowUpTasks} open follow-up {data.openFollowUpTasks === 1 ? "task" : "tasks"} on
the checklist.
</Alert>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
type="date"
size="small"
label="Follow up on"
value={date}
disabled={busy}
onChange={(e) => setDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Next action"
value={action}
disabled={busy}
onChange={(e) => setAction(e.target.value)}
/>
<Button variant="contained" disabled={busy} onClick={save}>Save</Button>
</Stack>
{data && !data.followUpAt && (
<Typography variant="body2" color="text.secondary">
No follow-up scheduled. Applications without one go quiet.
</Typography>
)}
</Stack>
</Shell>
);
}
+172
View File
@@ -0,0 +1,172 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ApplicationFollowUp, ApplicationInterviewPrep } from "./components/InterviewPrep";
import { api } from "./api";
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
patch: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
}));
const mockedApi = api as jest.Mocked<typeof api>;
const board = {
groups: [
{
category: "company-research",
label: "Company research",
items: [
{ id: 1, category: "company-research", title: "Funding history", content: "Series B in 2025.", source: "user", isPrepared: true, sortOrder: 1, updatedAtUtc: "2026-07-19T10:00:00Z" },
],
},
{
category: "behavioural",
label: "Behavioural questions",
items: [
{ id: 2, category: "behavioural", title: "Tell me about a conflict", content: null, source: "ai", isPrepared: false, sortOrder: 2, updatedAtUtc: "2026-07-19T10:00:00Z" },
],
},
],
total: 2,
prepared: 1,
percent: 50,
isInterviewStage: true,
aiSuggestionCount: 1,
};
const followUp = { followUpAt: "2026-07-26T00:00:00", nextAction: "Chase recruiter", responseReceived: false, openFollowUpTasks: 1 };
function routeGet(overrides: Record<string, any> = {}) {
mockedApi.get.mockImplementation((url: string) => {
if (url.endsWith("/follow-up")) return Promise.resolve({ data: overrides.followUp ?? followUp } as any);
return Promise.resolve({ data: overrides.board ?? board } as any);
});
}
beforeEach(() => jest.clearAllMocks());
test("prep renders grouped items with progress and marks AI-sourced ones", async () => {
routeGet();
render(<ApplicationInterviewPrep jobId={7} />);
// Each label appears twice: once as the group heading, once as a category option in the add form.
expect((await screen.findAllByText("Company research")).length).toBeGreaterThan(0);
expect(screen.getAllByText("Behavioural questions").length).toBeGreaterThan(0);
expect(screen.getByText("Funding history")).toBeInTheDocument();
expect(screen.getByText("1 of 2 ready")).toBeInTheDocument();
expect(screen.getByText("From AI")).toBeInTheDocument();
});
test("adding a prep item posts the chosen category and title", async () => {
routeGet();
mockedApi.post.mockResolvedValue({ data: board.groups[0].items[0] } as any);
render(<ApplicationInterviewPrep jobId={7} />);
fireEvent.change(await screen.findByLabelText(/Add a question, topic or note/i), {
target: { value: "What does success look like?" },
});
fireEvent.click(screen.getByRole("button", { name: "Add" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
"/jobapplications/7/interview-prep",
{ category: "company-research", title: "What does success look like?" },
));
});
test("marking an item ready patches it", async () => {
routeGet();
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);
render(<ApplicationInterviewPrep jobId={7} />);
fireEvent.click(await screen.findByRole("checkbox", { name: "Ready: Tell me about a conflict" }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith(
"/jobapplications/7/interview-prep/2", { isPrepared: true }));
});
test("an answer is only saved when the user asks", async () => {
routeGet();
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);
render(<ApplicationInterviewPrep jobId={7} />);
const boxes = await screen.findAllByPlaceholderText(/Your answer, in your own words/i);
fireEvent.change(boxes[1], { target: { value: "My STAR answer" } });
// Typing alone must not persist anything.
expect(mockedApi.patch).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /Save answer/i }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith(
"/jobapplications/7/interview-prep/2", { content: "My STAR answer" }));
});
test("deleting a prep item calls delete", async () => {
routeGet();
mockedApi.delete.mockResolvedValue({ data: undefined } as any);
render(<ApplicationInterviewPrep jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: "Delete: Funding history" }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/interview-prep/1"));
});
test("empty prep shows a useful empty state", async () => {
routeGet({ board: { groups: [], total: 0, prepared: 0, percent: 0, isInterviewStage: false, aiSuggestionCount: 0 } });
render(<ApplicationInterviewPrep jobId={7} />);
expect(await screen.findByText(/Nothing prepared yet/i)).toBeInTheDocument();
expect(screen.getByText(/has not reached an interview stage/i)).toBeInTheDocument();
});
test("prep surfaces a load error", async () => {
mockedApi.get.mockRejectedValue(new Error("boom"));
render(<ApplicationInterviewPrep jobId={7} />);
expect(await screen.findByText(/Could not load interview preparation/i)).toBeInTheDocument();
});
// ---------- follow-up ----------
test("follow-up loads the existing date and open checklist tasks", async () => {
routeGet();
render(<ApplicationFollowUp jobId={7} />);
expect(await screen.findByDisplayValue("2026-07-26")).toBeInTheDocument();
expect(screen.getByDisplayValue("Chase recruiter")).toBeInTheDocument();
expect(screen.getByText(/1 open follow-up task on the checklist/i)).toBeInTheDocument();
});
test("saving a follow-up sends the date and next action", async () => {
routeGet();
mockedApi.put.mockResolvedValue({ data: followUp } as any);
render(<ApplicationFollowUp jobId={7} />);
fireEvent.change(await screen.findByLabelText(/Follow up on/i), { target: { value: "2026-08-01" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/follow-up",
{ followUpAt: "2026-08-01", nextAction: "Chase recruiter" },
));
});
test("no follow-up shows an empty state", async () => {
routeGet({ followUp: { followUpAt: null, nextAction: null, responseReceived: false, openFollowUpTasks: 0 } });
render(<ApplicationFollowUp jobId={7} />);
expect(await screen.findByText(/No follow-up scheduled/i)).toBeInTheDocument();
});
@@ -25,6 +25,7 @@ import {
import {
ApplicationCoverLetterSection, ApplicationCvSection,
} from "../components/ApplicationAssets";
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
import {
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
} from "../applicationWorkspace";
@@ -102,6 +103,7 @@ export default function ApplicationWorkspacePage() {
{section === "analysis" && jobId > 0 && <ApplicationAnalysis jobId={jobId} />}
{section === "match" && jobId > 0 && <ApplicationMatch jobId={jobId} />}
{section === "timeline" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />}
{(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<AiWorkspacePanel jobId={jobId} />