3d74baef78
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>
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import { api } from "./api";
|
|
|
|
// Mirrors WorkspaceOverviewDto. Read-only aggregate — the workspace owns no data.
|
|
export type WorkspaceCv = {
|
|
variantId: number | null;
|
|
variantName: string | null;
|
|
themeId: string | null;
|
|
hasTailoredCvText: boolean;
|
|
updatedAtUtc: string | null;
|
|
};
|
|
|
|
export type WorkspaceActivity = { type: string; detail: string | null; at: string };
|
|
export type WorkspaceNextStep = { key: string; label: string; reason: string; section: string | null };
|
|
|
|
export type WorkspaceOverview = {
|
|
id: number;
|
|
jobTitle: string;
|
|
company: string | null;
|
|
location: string | null;
|
|
salary: string | null;
|
|
status: string;
|
|
stageGroup: string;
|
|
stageOrder: number;
|
|
dateApplied: string | null;
|
|
deadline: string | null;
|
|
followUpAt: string | null;
|
|
nextAction: string | null;
|
|
jobUrl: string | null;
|
|
hasJobDescription: boolean;
|
|
cv: WorkspaceCv;
|
|
hasCoverLetter: boolean;
|
|
documentCount: number;
|
|
hasPortfolio: boolean;
|
|
aiInteractionCount: number;
|
|
lastAiAtUtc: string | null;
|
|
recentActivity: WorkspaceActivity[];
|
|
nextStep: WorkspaceNextStep | null;
|
|
checklistProgress: ChecklistProgress | null;
|
|
};
|
|
|
|
// Milestone 2 — the application checklist. One workflow surface: system items seed from the same
|
|
// readiness signals the backend already computed, and the user owns everything after that.
|
|
export type ChecklistStatus = "pending" | "done" | "dismissed";
|
|
|
|
export type ChecklistItem = {
|
|
id: number;
|
|
systemKey: string | null;
|
|
title: string;
|
|
description: string | null;
|
|
category: string;
|
|
status: ChecklistStatus;
|
|
section: string | null;
|
|
sortOrder: number;
|
|
isSystemGenerated: boolean;
|
|
isAutoCompleted: boolean;
|
|
completedAt: string | null;
|
|
};
|
|
|
|
export type ChecklistProgress = { total: number; completed: number; dismissed: number; percent: number };
|
|
export type Checklist = { items: ChecklistItem[]; progress: ChecklistProgress };
|
|
|
|
export const CHECKLIST_CATEGORIES: { key: string; label: string }[] = [
|
|
{ key: "preparation", label: "Before applying" },
|
|
{ key: "submission", label: "Submitting" },
|
|
{ key: "follow-up", label: "Follow-up" },
|
|
{ key: "interview", label: "Interview" },
|
|
{ key: "custom", label: "Your own tasks" },
|
|
];
|
|
|
|
// Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands
|
|
// so the workspace is always usable rather than a shell of placeholders.
|
|
export type WorkspaceSectionKey =
|
|
| "overview" | "job-details" | "analysis" | "match" | "checklist" | "cv" | "cover-letter"
|
|
| "portfolio" | "documents" | "interview" | "timeline" | "notes" | "communication";
|
|
|
|
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; milestone?: number }[] = [
|
|
{ key: "overview", label: "Overview" },
|
|
{ key: "job-details", label: "Job Details" },
|
|
{ key: "analysis", label: "Analysis" },
|
|
{ key: "match", label: "Match" },
|
|
{ key: "checklist", label: "Checklist" },
|
|
{ key: "cv", label: "CV" },
|
|
{ key: "cover-letter", label: "Cover Letter" },
|
|
{ key: "portfolio", label: "Portfolio", milestone: 8 },
|
|
{ key: "documents", label: "Documents" },
|
|
{ key: "interview", label: "Interview Prep" },
|
|
{ key: "timeline", label: "Timeline" },
|
|
{ key: "notes", label: "Notes", milestone: 3 },
|
|
{ key: "communication", label: "Communication" },
|
|
];
|
|
|
|
export const applicationWorkspaceApi = {
|
|
overview: (jobId: number) =>
|
|
api.get<WorkspaceOverview>(`/jobapplications/${jobId}/workspace`).then((r) => r.data),
|
|
};
|
|
|
|
// Phase 5.3 — Application Intelligence. All three reads are deterministic and read-only; the AI
|
|
// narrative stays on the existing /ai routes, which require the user to ask for it.
|
|
export type TimelineEvent = {
|
|
id: number;
|
|
type: string;
|
|
category: string;
|
|
summary: string;
|
|
detail: string | null;
|
|
isMilestone: boolean;
|
|
at: string;
|
|
};
|
|
|
|
export type TimelineDay = { date: string; label: string; events: TimelineEvent[] };
|
|
|
|
export type Timeline = {
|
|
days: TimelineDay[];
|
|
milestones: TimelineEvent[];
|
|
categories: string[];
|
|
totalEvents: number;
|
|
};
|
|
|
|
export type JobAnalysis = {
|
|
role: string | null;
|
|
company: string | null;
|
|
location: string | null;
|
|
employmentType: string | null;
|
|
seniority: string | null;
|
|
salary: string | null;
|
|
technologies: string[];
|
|
skills: string[];
|
|
responsibilities: string[];
|
|
keywords: string[];
|
|
summary: string;
|
|
importantRequirements: string[];
|
|
interviewTopics: string[];
|
|
missingInformation: string[];
|
|
hasJobDescription: boolean;
|
|
aiSuggestionCount: number;
|
|
};
|
|
|
|
export type MatchEvidence = { title: string; subtitle: string | null; matched: string[] };
|
|
|
|
export type CareerMatch = {
|
|
score: number;
|
|
band: string;
|
|
hasEnoughSignal: boolean;
|
|
hasCareerProfile: boolean;
|
|
matchedSkills: string[];
|
|
missingSkills: string[];
|
|
relevantExperience: MatchEvidence[];
|
|
relevantProjects: MatchEvidence[];
|
|
suggestions: string[];
|
|
aiSuggestionCount: number;
|
|
};
|
|
|
|
export const TIMELINE_CATEGORY_LABELS: Record<string, string> = {
|
|
lifecycle: "Lifecycle",
|
|
stage: "Stage",
|
|
"follow-up": "Follow-up",
|
|
communication: "Communication",
|
|
ai: "AI",
|
|
};
|
|
|
|
export const applicationIntelligenceApi = {
|
|
timeline: (jobId: number, category?: string, milestonesOnly?: boolean) =>
|
|
api
|
|
.get<Timeline>(`/jobapplications/${jobId}/timeline`, {
|
|
params: { category: category || undefined, milestonesOnly: milestonesOnly || undefined },
|
|
})
|
|
.then((r) => r.data),
|
|
analysis: (jobId: number) =>
|
|
api.get<JobAnalysis>(`/jobapplications/${jobId}/analysis`).then((r) => r.data),
|
|
match: (jobId: number) => api.get<CareerMatch>(`/jobapplications/${jobId}/match`).then((r) => r.data),
|
|
};
|
|
|
|
// Phase 5.4 — Application Assets. CV variant CRUD, preview, PDF export and version history stay on
|
|
// /api/cv (the existing CV builder). These types cover only what is application-scoped.
|
|
export type CvVariantSummary = {
|
|
id: number;
|
|
name: string;
|
|
themeId: string;
|
|
publicSlug: string;
|
|
isPublic: boolean;
|
|
version: number;
|
|
jobApplicationId: number | null;
|
|
updatedAtUtc: string;
|
|
};
|
|
|
|
export type ApplicationCv = {
|
|
attachedVariantId: number | null;
|
|
attachedVariantName: string | null;
|
|
attachedThemeId: string | null;
|
|
attachedVersion: number | null;
|
|
attachedUpdatedAtUtc: string | null;
|
|
attachedIsPublic: boolean;
|
|
hasTailoredCvText: boolean;
|
|
availableVariants: CvVariantSummary[];
|
|
};
|
|
|
|
export type TailoringSuggestion = { kind: string; title: string; detail: string | null; items: string[] };
|
|
|
|
export type TailoringPlan = {
|
|
hasJobDescription: boolean;
|
|
hasCareerProfile: boolean;
|
|
hasAttachedVariant: boolean;
|
|
matchScore: number;
|
|
suggestions: TailoringSuggestion[];
|
|
aiSuggestionCount: number;
|
|
};
|
|
|
|
export type CoverLetterVersion = {
|
|
version: number;
|
|
source: string;
|
|
aiAction: string | null;
|
|
length: number;
|
|
createdAtUtc: string;
|
|
isCurrent: boolean;
|
|
};
|
|
|
|
export type CoverLetter = {
|
|
text: string | null;
|
|
currentVersion: number;
|
|
versions: CoverLetterVersion[];
|
|
aiSuggestionCount: number;
|
|
};
|
|
|
|
export const applicationAssetsApi = {
|
|
cv: (jobId: number) => api.get<ApplicationCv>(`/jobapplications/${jobId}/cv`).then((r) => r.data),
|
|
attachVariant: (jobId: number, variantId: number | null) =>
|
|
api.put<ApplicationCv>(`/jobapplications/${jobId}/cv`, { variantId }).then((r) => r.data),
|
|
tailoring: (jobId: number) =>
|
|
api.get<TailoringPlan>(`/jobapplications/${jobId}/tailoring`).then((r) => r.data),
|
|
coverLetter: (jobId: number) =>
|
|
api.get<CoverLetter>(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data),
|
|
saveCoverLetter: (jobId: number, text: string, source = "manual", aiAction?: string) =>
|
|
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
|
|
restoreCoverLetter: (jobId: number, version: number) =>
|
|
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),
|
|
add: (jobId: number, title: string, description?: string, category?: string) =>
|
|
api.post<ChecklistItem>(`/jobapplications/${jobId}/checklist`, { title, description, category }).then((r) => r.data),
|
|
update: (jobId: number, itemId: number, patch: Partial<Pick<ChecklistItem, "title" | "description" | "category" | "status">>) =>
|
|
api.patch<ChecklistItem>(`/jobapplications/${jobId}/checklist/${itemId}`, patch).then((r) => r.data),
|
|
remove: (jobId: number, itemId: number) =>
|
|
api.delete(`/jobapplications/${jobId}/checklist/${itemId}`).then(() => undefined),
|
|
reorder: (jobId: number, orderedIds: number[]) =>
|
|
api.put<Checklist>(`/jobapplications/${jobId}/checklist/order`, orderedIds).then((r) => r.data),
|
|
};
|