From b23e7b8178ad404b8e74857c3e7fdd6cdeedb4d4 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 08:53:27 -0600 Subject: [PATCH 01/30] feat(stages): add stage schema and migration, Withdrawn status, stage-type resolver and signup seeding Phase 1 of the job stages timeline. Replaces the vestigial Interview model with four models behind Job.statusId, which stays the column every existing consumer reads. - schema: JobStageType, JobStage, JobStageInterviewer and JobStagePrepQuestion in; Interview out, along with Contact.interviewId. JobStagePrepQuestion's questionId deliberately does not cascade. - migration: tables, the partial UNIQUE(jobId) WHERE isCurrent index, the Withdrawn JobStatus row, fourteen stage types per existing user, and a per-job backfill giving every job exactly one current stage. Verified on dev: 191 jobs, 191 current stages, 0 invariant breaks. - constants: Withdrawn appended to JOB_STATUSES, plus JOB_STAGES, TERMINAL_STAGE_STATUSES and STAGE_OUTCOMES. A stage type's value is canonicalizeEntityValue(label), not status.value, so "Offer Accepted" cannot mint a duplicate beside "offer-accepted". - resolve: resolveJobStageType and resolveStageTypeForStatusId, following the resolveEntity pattern rather than reusing it, since that delegate cannot carry statusId and sortOrder. - signup: seeds the fourteen types after the JobStatus upserts. - mock.actions: drops the now-unresolvable prisma.interview.deleteMany; job deletion cascades to JobStage. Co-Authored-By: Claude Opus 5 --- __tests__/auth.actions.spec.ts | 32 +++ __tests__/jobStageConstants.spec.ts | 47 ++++ __tests__/resolveJobStageType.spec.ts | 143 +++++++++++ .../migration.sql | 237 ++++++++++++++++++ prisma/schema.prisma | 86 ++++++- src/actions/auth.actions.ts | 22 +- src/actions/mock.actions.ts | 1 - src/lib/badge-colors.ts | 1 + src/lib/constants.ts | 38 +++ src/lib/data/jobStatusesData.ts | 5 + src/lib/jobs/resolve.ts | 60 +++++ 11 files changed, 660 insertions(+), 12 deletions(-) create mode 100644 __tests__/jobStageConstants.spec.ts create mode 100644 __tests__/resolveJobStageType.spec.ts create mode 100644 prisma/migrations/20260919144828_job_stages_timeline/migration.sql diff --git a/__tests__/auth.actions.spec.ts b/__tests__/auth.actions.spec.ts index b0a9863ec..90a13a79c 100644 --- a/__tests__/auth.actions.spec.ts +++ b/__tests__/auth.actions.spec.ts @@ -22,6 +22,10 @@ vi.mock("@/lib/db", () => { }, jobStatus: { upsert: vi.fn(), + findMany: vi.fn(), + }, + jobStageType: { + createMany: vi.fn(), }, }; return { default: mockPrisma }; @@ -61,6 +65,10 @@ vi.mock("next-auth", () => { describe("Auth Actions", () => { beforeEach(() => { vi.clearAllMocks(); + // Stage-type seeding reads the statuses back to map value -> id. + (prisma.jobStatus.findMany as any).mockResolvedValue( + JOB_STATUSES.map((s) => ({ id: `id-${s.value}`, value: s.value })), + ); }); describe("signup", () => { @@ -217,6 +225,30 @@ describe("Auth Actions", () => { }); }); + it("seeds the fourteen stage types after the statuses exist", async () => { + (prisma.user.findUnique as any).mockResolvedValue(null); + (bcrypt.hash as any).mockResolvedValue("hashed"); + (prisma.user.create as any).mockResolvedValue({ id: "new-user-id" }); + + await signup({ + name: "Test", + email: "test@example.com", + password: "Password1!", + }); + + const rows = (prisma.jobStageType.createMany as any).mock.calls[0][0].data; + expect(rows).toHaveLength(14); + expect(rows.every((r: any) => r.createdBy === "new-user-id")).toBe(true); + expect(rows.every((r: any) => typeof r.statusId === "string")).toBe(true); + expect(rows.find((r: any) => r.value === "offer accepted")).toBeTruthy(); + + const statusOrder = (prisma.jobStatus.upsert as any).mock + .invocationCallOrder[0]; + const stageOrder = (prisma.jobStageType.createMany as any).mock + .invocationCallOrder[0]; + expect(statusOrder).toBeLessThan(stageOrder); + }); + it("uses the canonical form of each label as the role value", () => { for (const role of CONTACT_ROLES) { expect(role.value).toBe(canonicalizeEntityValue(role.label)); diff --git a/__tests__/jobStageConstants.spec.ts b/__tests__/jobStageConstants.spec.ts new file mode 100644 index 000000000..d8baf8465 --- /dev/null +++ b/__tests__/jobStageConstants.spec.ts @@ -0,0 +1,47 @@ +import { JOB_STAGES, JOB_STATUSES } from "@/lib/constants"; +import { canonicalizeEntityValue } from "@/lib/jobs/canonicalize"; +import { JOB_STATUS_BADGE_COLORS } from "@/lib/badge-colors"; + +describe("JOB_STAGES", () => { + it("has fourteen entries with unique values and sequential sortOrder", () => { + expect(JOB_STAGES).toHaveLength(14); + const values = JOB_STAGES.map((s) => s.value); + expect(new Set(values).size).toBe(14); + expect(JOB_STAGES.map((s) => s.sortOrder)).toEqual( + JOB_STAGES.map((_, i) => i), + ); + }); + + it("stores each value as the canonical form of its own label", () => { + for (const stage of JOB_STAGES) { + expect(stage.value).toBe(canonicalizeEntityValue(stage.label)); + } + }); + + it("points every stage at a real JobStatus value", () => { + const statuses = new Set(JOB_STATUSES.map((s) => s.value)); + for (const stage of JOB_STAGES) { + expect(statuses.has(stage.status)).toBe(true); + } + }); + + // Decision 19: the mapping must be total in both directions, so every + // status has exactly one status-named stage type to resolve back to. + it("carries exactly one status-named stage per JobStatus", () => { + for (const status of JOB_STATUSES) { + const named = JOB_STAGES.filter( + (s) => s.value === canonicalizeEntityValue(status.label), + ); + expect(named).toHaveLength(1); + expect(named[0].status).toBe(status.value); + } + }); + + it("adds withdrawn as an appended status with a slate badge", () => { + expect(JOB_STATUSES[JOB_STATUSES.length - 1]).toEqual({ + label: "Withdrawn", + value: "withdrawn", + }); + expect(JOB_STATUS_BADGE_COLORS.withdrawn).toBe("slate"); + }); +}); diff --git a/__tests__/resolveJobStageType.spec.ts b/__tests__/resolveJobStageType.spec.ts new file mode 100644 index 000000000..c4dc66c1b --- /dev/null +++ b/__tests__/resolveJobStageType.spec.ts @@ -0,0 +1,143 @@ +import { + resolveJobStageType, + resolveStageTypeForStatusId, +} from "@/lib/jobs/resolve"; +import prisma from "@/lib/db"; + +vi.mock("@/lib/db", () => ({ + default: { + jobStageType: { + findUnique: vi.fn(), + create: vi.fn(), + aggregate: vi.fn(), + }, + jobStatus: { findUnique: vi.fn() }, + }, +})); + +const db = prisma as any; + +describe("resolveJobStageType", () => { + beforeEach(() => { + vi.clearAllMocks(); + db.jobStageType.aggregate.mockResolvedValue({ _max: { sortOrder: 13 } }); + }); + + it("matches an existing type on canonical value", async () => { + db.jobStageType.findUnique.mockResolvedValue({ id: "t1", label: "On-site" }); + + const res = await resolveJobStageType(" ON-SITE ", "u1", "s1"); + + expect(db.jobStageType.findUnique).toHaveBeenCalledWith({ + where: { value_createdBy: { value: "on-site", createdBy: "u1" } }, + }); + expect(res).toEqual({ id: "t1", label: "On-site", created: false }); + expect(db.jobStageType.create).not.toHaveBeenCalled(); + }); + + // D7: canonicalizeEntityValue folds case, diacritics, commas and whitespace + // and nothing else, so "On-site" and "Onsite" are genuinely two types. This + // test pins the limit so nobody later reads the resolver as fuzzy matching. + it("does not fold hyphens, so On-site and Onsite are different types", async () => { + db.jobStageType.findUnique.mockResolvedValue(null); + db.jobStageType.create.mockResolvedValue({ id: "t9", label: "Onsite" }); + + await resolveJobStageType("Onsite", "u1", "s1"); + + expect(db.jobStageType.findUnique).toHaveBeenCalledWith({ + where: { value_createdBy: { value: "onsite", createdBy: "u1" } }, + }); + expect(db.jobStageType.create).toHaveBeenCalled(); + }); + + // Only `value` collapses internal whitespace; the label is stored trimmed + // but otherwise verbatim, exactly as resolveEntity does for the other five. + it("creates with the trimmed label, canonical value and the next sortOrder", async () => { + db.jobStageType.findUnique.mockResolvedValue(null); + db.jobStageType.create.mockResolvedValue({ + id: "t2", + label: "Panel Interview", + }); + + const res = await resolveJobStageType(" Panel Interview ", "u1", "s9"); + + expect(db.jobStageType.create).toHaveBeenCalledWith({ + data: { + label: "Panel Interview", + value: "panel interview", + statusId: "s9", + sortOrder: 14, + createdBy: "u1", + }, + }); + expect(res.created).toBe(true); + }); + + it("returns the winner's row when a concurrent create wins the unique race", async () => { + db.jobStageType.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "t3", label: "Panel" }); + db.jobStageType.create.mockRejectedValue({ code: "P2002" }); + + const res = await resolveJobStageType("Panel", "u1", "s1"); + + expect(res).toEqual({ id: "t3", label: "Panel", created: false }); + }); + + it("rejects an empty label", async () => { + await expect(resolveJobStageType(" ", "u1", "s1")).rejects.toThrow( + "A non-empty label is required", + ); + }); +}); + +describe("resolveStageTypeForStatusId", () => { + beforeEach(() => { + vi.clearAllMocks(); + db.jobStageType.aggregate.mockResolvedValue({ _max: { sortOrder: 13 } }); + }); + + // The two hyphenated offer statuses are why the key is the canonical LABEL + // and not status.value: "offer-accepted" would never match the seeded row. + it("looks the type up by the canonical form of the status label", async () => { + db.jobStatus.findUnique.mockResolvedValue({ + id: "s-oa", + label: "Offer Accepted", + value: "offer-accepted", + }); + db.jobStageType.findUnique.mockResolvedValue({ + id: "t-oa", + label: "Offer Accepted", + }); + + const id = await resolveStageTypeForStatusId("s-oa", "u1"); + + expect(db.jobStageType.findUnique).toHaveBeenCalledWith({ + where: { value_createdBy: { value: "offer accepted", createdBy: "u1" } }, + }); + expect(id).toBe("t-oa"); + }); + + it("creates the status-named type when the user has deleted it", async () => { + db.jobStatus.findUnique.mockResolvedValue({ + id: "s-w", + label: "Withdrawn", + value: "withdrawn", + }); + db.jobStageType.findUnique.mockResolvedValue(null); + db.jobStageType.create.mockResolvedValue({ id: "t-w", label: "Withdrawn" }); + + const id = await resolveStageTypeForStatusId("s-w", "u1"); + + expect(id).toBe("t-w"); + expect(db.jobStageType.create).toHaveBeenCalled(); + }); + + it("throws when the status does not exist", async () => { + db.jobStatus.findUnique.mockResolvedValue(null); + + await expect(resolveStageTypeForStatusId("nope", "u1")).rejects.toThrow( + "Job status not found", + ); + }); +}); diff --git a/prisma/migrations/20260919144828_job_stages_timeline/migration.sql b/prisma/migrations/20260919144828_job_stages_timeline/migration.sql new file mode 100644 index 000000000..53f449b48 --- /dev/null +++ b/prisma/migrations/20260919144828_job_stages_timeline/migration.sql @@ -0,0 +1,237 @@ +/* + Warnings: + + - You are about to drop the `Interview` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the column `interviewId` on the `Contact` table. All the data in the column will be lost. + +*/ +-- Data-only: JobStatus rows are seeded at signup, so existing databases need +-- the new status inserted. Shape copied from 20260904000000_job_status_offer_outcomes. +INSERT OR IGNORE INTO "JobStatus" ("id", "label", "value") VALUES + ('3f2b6c41-88d5-4a0e-9d0a-6a1c9f0b7e22', 'Withdrawn', 'withdrawn'); + +-- CreateTable +CREATE TABLE "JobStageType" ( + "id" TEXT NOT NULL PRIMARY KEY, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "statusId" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdBy" TEXT NOT NULL, + CONSTRAINT "JobStageType_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "JobStatus" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "JobStageType_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "JobStage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "jobId" TEXT NOT NULL, + "stageTypeId" TEXT NOT NULL, + "occurredAt" DATETIME, + "isCurrent" BOOLEAN NOT NULL DEFAULT false, + "outcome" TEXT, + "notes" TEXT, + "durationMins" INTEGER, + "format" TEXT, + "location" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "JobStage_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "JobStage_stageTypeId_fkey" FOREIGN KEY ("stageTypeId") REFERENCES "JobStageType" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "JobStageInterviewer" ( + "id" TEXT NOT NULL PRIMARY KEY, + "stageId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "JobStageInterviewer_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "JobStage" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "JobStageInterviewer_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "JobStagePrepQuestion" ( + "id" TEXT NOT NULL PRIMARY KEY, + "stageId" TEXT NOT NULL, + "questionId" TEXT NOT NULL, + "asked" BOOLEAN NOT NULL DEFAULT false, + "askedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "JobStagePrepQuestion_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "JobStage" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "JobStagePrepQuestion_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Contact" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "title" TEXT, + "email" TEXT, + "phone" TEXT, + "linkedinUrl" TEXT, + "companyId" TEXT, + "locationId" TEXT, + "relationship" TEXT, + "workedAtCompanyId" TEXT, + "workedFrom" DATETIME, + "workedTo" DATETIME, + "roleId" TEXT, + "notes" TEXT, + "lastContactedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "createdBy" TEXT NOT NULL, + CONSTRAINT "Contact_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Contact_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Contact_workedAtCompanyId_fkey" FOREIGN KEY ("workedAtCompanyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Contact_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ContactRole" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Contact_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Contact" ("companyId", "createdAt", "createdBy", "email", "id", "lastContactedAt", "linkedinUrl", "locationId", "name", "notes", "phone", "relationship", "roleId", "title", "updatedAt", "workedAtCompanyId", "workedFrom", "workedTo") SELECT "companyId", "createdAt", "createdBy", "email", "id", "lastContactedAt", "linkedinUrl", "locationId", "name", "notes", "phone", "relationship", "roleId", "title", "updatedAt", "workedAtCompanyId", "workedFrom", "workedTo" FROM "Contact"; +DROP TABLE "Contact"; +ALTER TABLE "new_Contact" RENAME TO "Contact"; +CREATE INDEX "Contact_createdBy_idx" ON "Contact"("createdBy"); +CREATE INDEX "Contact_companyId_idx" ON "Contact"("companyId"); +CREATE INDEX "Contact_workedAtCompanyId_idx" ON "Contact"("workedAtCompanyId"); +CREATE INDEX "Contact_roleId_idx" ON "Contact"("roleId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- DropTable +-- After the Contact redefinition: the old Contact still holds the interviewId +-- FK into Interview until the rename completes. +PRAGMA foreign_keys=off; +DROP TABLE "Interview"; +PRAGMA foreign_keys=on; + +-- CreateIndex +CREATE INDEX "JobStageType_createdBy_idx" ON "JobStageType"("createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStageType_value_createdBy_key" ON "JobStageType"("value", "createdBy"); + +-- CreateIndex +CREATE INDEX "JobStage_jobId_idx" ON "JobStage"("jobId"); + +-- CreateIndex +CREATE INDEX "JobStage_stageTypeId_idx" ON "JobStage"("stageTypeId"); + +-- Exactly one current stage per job. Partial index (not expressible in the +-- Prisma DSL) so historical stages are unaffected. Same mechanism as +-- 20260710000001_automation_run_single_active. +CREATE UNIQUE INDEX "JobStage_jobId_current_key" +ON "JobStage"("jobId") +WHERE "isCurrent" = 1; + +-- CreateIndex +CREATE INDEX "JobStageInterviewer_contactId_idx" ON "JobStageInterviewer"("contactId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStageInterviewer_stageId_contactId_key" ON "JobStageInterviewer"("stageId", "contactId"); + +-- CreateIndex +CREATE INDEX "JobStagePrepQuestion_questionId_idx" ON "JobStagePrepQuestion"("questionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStagePrepQuestion_stageId_questionId_key" ON "JobStagePrepQuestion"("stageId", "questionId"); + +-- Seed the fourteen default stage types for existing users. New accounts get +-- these from signup(). `value` is canonicalizeEntityValue(label), which is how +-- a status resolves back to its stage type (no isDefaultForStatus column). +INSERT INTO "JobStageType" ("id", "label", "value", "statusId", "sortOrder", "createdBy") +SELECT lower( + substr(hex(randomblob(16)), 1, 8) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 12) +), t."label", t."value", s."id", t."sortOrder", u."id" +FROM "User" u +CROSS JOIN ( + SELECT 'New' AS "label", 'new' AS "value", 'new' AS "status", 0 AS "sortOrder" + UNION ALL SELECT 'Draft', 'draft', 'draft', 1 + UNION ALL SELECT 'Applied', 'applied', 'applied', 2 + UNION ALL SELECT 'Interview', 'interview', 'interview', 3 + UNION ALL SELECT '1st Screening Interview', '1st screening interview', 'interview', 4 + UNION ALL SELECT '2nd Technical Interview', '2nd technical interview', 'interview', 5 + UNION ALL SELECT 'Final / Onsite Interview', 'final / onsite interview', 'interview', 6 + UNION ALL SELECT 'Offer', 'offer', 'offer', 7 + UNION ALL SELECT 'Offer Accepted', 'offer accepted', 'offer-accepted', 8 + UNION ALL SELECT 'Offer Declined', 'offer declined', 'offer-declined', 9 + UNION ALL SELECT 'Rejected', 'rejected', 'rejected', 10 + UNION ALL SELECT 'Expired', 'expired', 'expired', 11 + UNION ALL SELECT 'Archived', 'archived', 'archived', 12 + UNION ALL SELECT 'Withdrawn', 'withdrawn', 'withdrawn', 13 +) t +JOIN "JobStatus" s ON s."value" = t."status"; + +-- Backfill, part 1 of 4: a New stage at Job.createdAt for every job. The +-- createdAt offsets below are deliberate: stages tiebreak on createdAt when +-- occurredAt is null, and CURRENT_TIMESTAMP is constant within a statement. +INSERT INTO "JobStage" ("id", "jobId", "stageTypeId", "occurredAt", "isCurrent", "createdAt", "updatedAt") +SELECT lower( + substr(hex(randomblob(16)), 1, 8) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 12) +), j."id", t."id", j."createdAt", 0, + datetime('now', '-3 seconds'), datetime('now', '-3 seconds') +FROM "Job" j +JOIN "JobStageType" t ON t."createdBy" = j."userId" AND t."value" = 'new'; + +-- Backfill, part 2 of 4: an Applied stage at Job.appliedDate, only where the +-- job is actually applied. appliedDate is taken verbatim — a null one leaves +-- the stage undated rather than inventing a timestamp. +INSERT INTO "JobStage" ("id", "jobId", "stageTypeId", "occurredAt", "isCurrent", "createdAt", "updatedAt") +SELECT lower( + substr(hex(randomblob(16)), 1, 8) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 12) +), j."id", t."id", j."appliedDate", 0, + datetime('now', '-2 seconds'), datetime('now', '-2 seconds') +FROM "Job" j +JOIN "JobStageType" t ON t."createdBy" = j."userId" AND t."value" = 'applied' +WHERE j."applied" = 1; + +-- Backfill, part 3 of 4: an undated stage matching the job's current status, +-- marked current. Skipped where it would repeat one of the two stages above — +-- a job still at New, or at Applied with an Applied stage already written. +-- The CASE is the status -> stage-type reverse lookup: canonical form of the +-- status LABEL, which differs from status.value for the two offer outcomes. +INSERT INTO "JobStage" ("id", "jobId", "stageTypeId", "occurredAt", "isCurrent", "createdAt", "updatedAt") +SELECT lower( + substr(hex(randomblob(16)), 1, 8) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 12) +), j."id", t."id", NULL, 1, + datetime('now', '-1 seconds'), datetime('now', '-1 seconds') +FROM "Job" j +JOIN "JobStatus" s ON s."id" = j."statusId" +JOIN "JobStageType" t ON t."createdBy" = j."userId" AND t."value" = CASE s."value" + WHEN 'offer-accepted' THEN 'offer accepted' + WHEN 'offer-declined' THEN 'offer declined' + ELSE s."value" +END +WHERE s."value" <> 'new' + AND NOT (s."value" = 'applied' AND j."applied" = 1); + +-- Backfill, part 4 of 4: where part 3 was skipped, the already-written dated +-- stage becomes the current one. Every backfilled job now has exactly one. +UPDATE "JobStage" SET "isCurrent" = 1 +WHERE "id" IN ( + SELECT st."id" + FROM "JobStage" st + JOIN "Job" j ON j."id" = st."jobId" + JOIN "JobStatus" s ON s."id" = j."statusId" + JOIN "JobStageType" t ON t."id" = st."stageTypeId" + WHERE (s."value" = 'new' AND t."value" = 'new') + OR (s."value" = 'applied' AND j."applied" = 1 AND t."value" = 'applied') +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17c44665a..475c3891b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { JobSource JobSource[] Contact Contact[] ContactRole ContactRole[] + JobStageType JobStageType[] Profile Profile[] Activity Activity[] ActivityType ActivityType[] @@ -241,10 +242,8 @@ model Contact { createdBy String // userId user User @relation(fields: [createdBy], references: [id]) - Interview Interview? @relation(fields: [interviewId], references: [id]) - interviewId String? - jobLinks JobContact[] + stageLinks JobStageInterviewer[] @@index([createdBy]) @@index([companyId]) @@ -295,6 +294,7 @@ model JobStatus { label String value String @unique jobs Job[] + stageTypes JobStageType[] } model Location { @@ -356,12 +356,77 @@ model JobSource { @@unique([value, createdBy]) } -model Interview { - id String @id @default(uuid()) - createdAt DateTime - jobId String - job Job? @relation(fields: [jobId], references: [id]) - interviewers Contact[] +// The kind of stage. Mirrors ContactRole in ownership and resolution, and +// JobSource in being user-extensible. statusId is both the derived-status +// source and the interview-kind discriminator (spec decisions 3 and 10). +model JobStageType { + id String @id @default(uuid()) + label String + value String + statusId String + Status JobStatus @relation(fields: [statusId], references: [id]) + sortOrder Int @default(0) + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + stages JobStage[] + + @@unique([value, createdBy]) + @@index([createdBy]) +} + +// One entry in a job's timeline. No ownership column: a stage has no meaning +// apart from its job, so ownership traverses Job: { userId }. +model JobStage { + id String @id @default(uuid()) + jobId String + Job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) + stageTypeId String + StageType JobStageType @relation(fields: [stageTypeId], references: [id]) + occurredAt DateTime? + isCurrent Boolean @default(false) + outcome String? + notes String? + durationMins Int? + format String? + location String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + interviewers JobStageInterviewer[] + prepQuestions JobStagePrepQuestion[] + + @@index([jobId]) + @@index([stageTypeId]) + // Also has a hand-written partial unique index (not expressible here): + // UNIQUE(jobId) WHERE isCurrent = 1 + // See migrations/20260919000000_job_stages_timeline +} + +model JobStageInterviewer { + id String @id @default(uuid()) + stageId String + Stage JobStage @relation(fields: [stageId], references: [id], onDelete: Cascade) + contactId String + Contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([stageId, contactId]) + @@index([contactId]) +} + +// questionId is deliberately NOT cascading: erasing the record that a question +// was asked in a real interview is worse than a delete that fails loudly. +model JobStagePrepQuestion { + id String @id @default(uuid()) + stageId String + Stage JobStage @relation(fields: [stageId], references: [id], onDelete: Cascade) + questionId String + Question Question @relation(fields: [questionId], references: [id]) + asked Boolean @default(false) + askedAt DateTime? + createdAt DateTime @default(now()) + + @@unique([stageId, questionId]) + @@index([questionId]) } model Job { @@ -387,7 +452,6 @@ model Job { salaryRange String? Location Location? @relation(fields: [locationId], references: [id]) locationId String? - Interview Interview[] Resume Resume? @relation(fields: [resumeId], references: [id]) resumeId String? CoverLetter CoverLetter? @relation(fields: [coverLetterId], references: [id]) @@ -395,6 +459,7 @@ model Job { Notes Note[] tags Tag[] contactLinks JobContact[] + stages JobStage[] // Automation discovery fields automationId String? @@ -589,6 +654,7 @@ model Question { createdBy String user User @relation(fields: [createdBy], references: [id]) tags Tag[] + prepLinks JobStagePrepQuestion[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdVia String? // MCP provenance: which agent/token created this, mirrors Job.createdVia diff --git a/src/actions/auth.actions.ts b/src/actions/auth.actions.ts index 47aa2b1c1..18e8cbc05 100644 --- a/src/actions/auth.actions.ts +++ b/src/actions/auth.actions.ts @@ -5,7 +5,12 @@ import { delay } from "@/utils/delay"; import prisma from "@/lib/db"; import bcrypt from "bcryptjs"; import { SignupFormSchema } from "@/models/signupForm.schema"; -import { CONTACT_ROLES, JOB_SOURCES, JOB_STATUSES } from "@/lib/constants"; +import { + CONTACT_ROLES, + JOB_SOURCES, + JOB_STAGES, + JOB_STATUSES, +} from "@/lib/constants"; export async function signup(formData: { name: string; @@ -57,6 +62,21 @@ export async function signup(formData: { }); } + // Stage types point at JobStatus, so this must follow the upsert loop. + const statusRows = await prisma.jobStatus.findMany({ + select: { id: true, value: true }, + }); + const statusIdByValue = new Map(statusRows.map((s) => [s.value, s.id])); + await prisma.jobStageType.createMany({ + data: JOB_STAGES.map((stage) => ({ + label: stage.label, + value: stage.value, + statusId: statusIdByValue.get(stage.status)!, + sortOrder: stage.sortOrder, + createdBy: newUser.id, + })), + }); + return { success: true }; } diff --git a/src/actions/mock.actions.ts b/src/actions/mock.actions.ts index 03e014d79..cd74816e3 100644 --- a/src/actions/mock.actions.ts +++ b/src/actions/mock.actions.ts @@ -611,7 +611,6 @@ export const clearMockJobsAction = async (): Promise => { // Delete related records first (FK constraints) await prisma.note.deleteMany({ where: { jobId: { in: jobIds } } }); - await prisma.interview.deleteMany({ where: { jobId: { in: jobIds } } }); // Disconnect tags (M2M) await Promise.all( diff --git a/src/lib/badge-colors.ts b/src/lib/badge-colors.ts index c6fdd61e4..e3bd8087c 100644 --- a/src/lib/badge-colors.ts +++ b/src/lib/badge-colors.ts @@ -29,6 +29,7 @@ export const JOB_STATUS_BADGE_COLORS: Record = { rejected: "red", expired: "amber", archived: "slate", + withdrawn: "slate", }; // job.Status.value is a plain string from the DB, not the literal union, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9944fe3a6..ec6cdce39 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -301,6 +301,44 @@ export const JOB_STATUSES = [ { label: "Rejected", value: "rejected" }, { label: "Expired", value: "expired" }, { label: "Archived", value: "archived" }, + { label: "Withdrawn", value: "withdrawn" }, +] as const; + +// Seeded per user at signup and backfilled by the stages migration. `value` +// must equal canonicalizeEntityValue(label): the status -> stage-type reverse +// lookup keys on the status LABEL's canonical form, not on status.value, so +// "Offer Accepted" cannot mint a second type beside "offer-accepted". +export const JOB_STAGES = [ + { label: "New", value: "new", status: "new", sortOrder: 0 }, + { label: "Draft", value: "draft", status: "draft", sortOrder: 1 }, + { label: "Applied", value: "applied", status: "applied", sortOrder: 2 }, + { label: "Interview", value: "interview", status: "interview", sortOrder: 3 }, + { label: "1st Screening Interview", value: "1st screening interview", status: "interview", sortOrder: 4 }, + { label: "2nd Technical Interview", value: "2nd technical interview", status: "interview", sortOrder: 5 }, + { label: "Final / Onsite Interview", value: "final / onsite interview", status: "interview", sortOrder: 6 }, + { label: "Offer", value: "offer", status: "offer", sortOrder: 7 }, + { label: "Offer Accepted", value: "offer accepted", status: "offer-accepted", sortOrder: 8 }, + { label: "Offer Declined", value: "offer declined", status: "offer-declined", sortOrder: 9 }, + { label: "Rejected", value: "rejected", status: "rejected", sortOrder: 10 }, + { label: "Expired", value: "expired", status: "expired", sortOrder: 11 }, + { label: "Archived", value: "archived", status: "archived", sortOrder: 12 }, + { label: "Withdrawn", value: "withdrawn", status: "withdrawn", sortOrder: 13 }, +] as const; + +// Statuses whose stage types render greyed at the tail of the stepper when the +// job has not reached them (D6). Offer alone: the greyed tail reads as what a +// job is working toward, and rejected/withdrawn are alternative endings, not +// next steps. Both stay one click away in Add Stage. +export const TERMINAL_STAGE_STATUSES = ["offer"] as const; + +// The fixed outcome set a stage may carry (spec's JobStage.outcome). +export const STAGE_OUTCOMES = [ + { label: "Scheduled", value: "scheduled" }, + { label: "Completed", value: "completed" }, + { label: "Passed", value: "passed" }, + { label: "Failed", value: "failed" }, + { label: "No-show", value: "no-show" }, + { label: "Cancelled", value: "cancelled" }, ] as const; // Zod's z.enum needs a non-empty tuple; JOB_STATUSES is the source of truth. diff --git a/src/lib/data/jobStatusesData.ts b/src/lib/data/jobStatusesData.ts index e8a04d7ea..414c97f39 100644 --- a/src/lib/data/jobStatusesData.ts +++ b/src/lib/data/jobStatusesData.ts @@ -44,4 +44,9 @@ export const JOB_STATUSES = [ label: "Archived", value: "archived", }, + { + id: "3f2b6c41-88d5-4a0e-9d0a-6a1c9f0b7e22", + label: "Withdrawn", + value: "withdrawn", + }, ]; diff --git a/src/lib/jobs/resolve.ts b/src/lib/jobs/resolve.ts index ee49334e3..b143b39a3 100644 --- a/src/lib/jobs/resolve.ts +++ b/src/lib/jobs/resolve.ts @@ -153,6 +153,66 @@ export async function resolveTags( return { resolved, dropped }; } +// Not resolveEntity: that delegate creates exactly { label, value, createdBy } +// and cannot carry statusId and sortOrder, so the pattern is followed rather +// than the function reused (spec decision 3). +export async function resolveJobStageType( + label: string, + userId: string, + statusId: string, +): Promise { + const trimmed = label.trim(); + const value = canonicalizeEntityValue(trimmed); + if (!value) throw new Error("A non-empty label is required"); + + const existing = await prisma.jobStageType.findUnique({ + where: { value_createdBy: { value, createdBy: userId } }, + }); + if (existing) return { id: existing.id, label: existing.label, created: false }; + + const max = await prisma.jobStageType.aggregate({ + where: { createdBy: userId }, + _max: { sortOrder: true }, + }); + + try { + const created = await prisma.jobStageType.create({ + data: { + label: trimmed, + value, + statusId, + sortOrder: (max._max.sortOrder ?? -1) + 1, + createdBy: userId, + }, + }); + return { id: created.id, label: created.label, created: true }; + } catch (err: any) { + if (err?.code === "P2002") { + const winner = await prisma.jobStageType.findUnique({ + where: { value_createdBy: { value, createdBy: userId } }, + }); + if (winner) return { id: winner.id, label: winner.label, created: false }; + } + throw err; + } +} + +// The status -> stage-type reverse lookup. Keyed on the canonical form of the +// status LABEL, not status.value: canonicalizeEntityValue("Offer Accepted") is +// "offer accepted", so keying on "offer-accepted" would mint a duplicate. +export async function resolveStageTypeForStatusId( + statusId: string, + userId: string, +): Promise { + const status = await prisma.jobStatus.findUnique({ + where: { id: statusId }, + select: { id: true, label: true }, + }); + if (!status) throw new Error("Job status not found"); + const resolved = await resolveJobStageType(status.label, userId, status.id); + return resolved.id; +} + // Bucket B — resolve-ONLY, never create // A caller-correctable bad value, as opposed to a database or programming From 854d3cffee0d46c3ff83c3b476fb0a30c12dfbf3 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 09:02:07 -0600 Subject: [PATCH 02/30] feat(backup): add the four stage models to MODEL_SPECS, INSERT_ORDER and the Zod schema, export stage-type status by value, drop Interview MODEL_SPECS and BackupDataSchema land together because manifest.ts's countRows walks INSERT_ORDER while indexing BackupData, so neither compiles green alone. Question moves ahead of the stage models: JobStagePrepQuestion.questionId is Restrict, so the reversed DELETE_ORDER must clear prep rows before questions. Co-Authored-By: Claude Opus 5 --- __tests__/backupExport.spec.ts | 42 ++++++++++++++- __tests__/backupImportGuards.spec.ts | 6 ++- __tests__/backupOrdering.spec.ts | 36 +++++++++++-- __tests__/backupSchema.spec.ts | 77 ++++++++++++++++++++++++++++ src/lib/backup/export.ts | 25 ++++++--- src/lib/backup/idmap.ts | 1 - src/lib/backup/ordering.ts | 43 ++++++++++++++-- src/lib/backup/schema.ts | 52 +++++++++++++++++-- 8 files changed, 258 insertions(+), 24 deletions(-) diff --git a/__tests__/backupExport.spec.ts b/__tests__/backupExport.spec.ts index 3757df0de..27676ee60 100644 --- a/__tests__/backupExport.spec.ts +++ b/__tests__/backupExport.spec.ts @@ -20,8 +20,10 @@ vi.mock("@/lib/db", () => { "company", "jobTitle", "location", "jobSource", "tag", "activityType", "profile", "file", "resume", "contactInfo", "summary", "resumeSection", "workExperience", "education", "licenseOrCertification", "otherSection", - "skill", "coverLetter", "automation", "job", "note", "interview", - "contact", "contactRole", "jobContact", "task", "activity", "question", "automationRun", "userSettings", + "skill", "coverLetter", "automation", "job", "note", + "contact", "contactRole", "jobContact", "jobStageType", "jobStage", + "jobStageInterviewer", "jobStagePrepQuestion", + "task", "activity", "question", "automationRun", "userSettings", ]) { mock[key] = delegate(); } @@ -115,6 +117,42 @@ describe("collectBackupData", () => { expect(data.Question[0]).not.toHaveProperty("tags"); }); + // D3: a raw statusId points at nothing after a restore into a fresh database. + it("replaces JobStageType.statusId with the status value and drops createdBy", async () => { + mockDb.jobStageType.findMany.mockResolvedValueOnce([ + { + id: "t1", + label: "Final / Onsite Interview", + value: "final / onsite interview", + statusId: "status-row-1", + Status: { value: "interview" }, + sortOrder: 6, + createdBy: "user-1", + }, + ]); + + const data = await collectBackupData("user-1"); + + expect(data.JobStageType[0].statusValue).toBe("interview"); + expect(data.JobStageType[0]).not.toHaveProperty("statusId"); + expect(data.JobStageType[0]).not.toHaveProperty("Status"); + expect(data.JobStageType[0]).not.toHaveProperty("createdBy"); + }); + + // A seeded Withdrawn stage type predates any withdrawn job, so scoping this + // read to the user's jobs alone would leave its statusValue unresolvable. + it("reads the statuses reached by a stage type as well as by a job", async () => { + await collectBackupData("user-1"); + const call = (db as unknown as { jobStatus: { findMany: ReturnType } }) + .jobStatus.findMany.mock.calls[0][0]; + expect(call.where).toEqual({ + OR: [ + { jobs: { some: { userId: "user-1" } } }, + { stageTypes: { some: { createdBy: "user-1" } } }, + ], + }); + }); + it("carries the referenced job statuses as label/value pairs", async () => { (db as unknown as { jobStatus: { findMany: ReturnType } }) .jobStatus.findMany.mockResolvedValueOnce([ diff --git a/__tests__/backupImportGuards.spec.ts b/__tests__/backupImportGuards.spec.ts index d63c8001c..b25992f21 100644 --- a/__tests__/backupImportGuards.spec.ts +++ b/__tests__/backupImportGuards.spec.ts @@ -30,8 +30,10 @@ vi.mock("@/lib/db", () => { "company", "jobTitle", "location", "jobSource", "tag", "activityType", "profile", "file", "resume", "contactInfo", "summary", "resumeSection", "workExperience", "education", "licenseOrCertification", "otherSection", - "skill", "coverLetter", "automation", "job", "note", "interview", - "contact", "contactRole", "jobContact", "task", "activity", "question", "userSettings", + "skill", "coverLetter", "automation", "job", "note", + "contact", "contactRole", "jobContact", "jobStageType", "jobStage", + "jobStageInterviewer", "jobStagePrepQuestion", + "task", "activity", "question", "userSettings", "chatConversation", ]) { mock[key] = counted(); diff --git a/__tests__/backupOrdering.spec.ts b/__tests__/backupOrdering.spec.ts index 6ddb3d5df..272a4cee8 100644 --- a/__tests__/backupOrdering.spec.ts +++ b/__tests__/backupOrdering.spec.ts @@ -66,7 +66,6 @@ describe("backup ordering", () => { it("remaps both of Contact's company FKs, not just the current employer", () => { expect(MODEL_SPECS.Contact.fks).toEqual({ - interviewId: "Interview", companyId: "Company", locationId: "Location", workedAtCompanyId: "Company", @@ -74,12 +73,13 @@ describe("backup ordering", () => { }); }); - it("marks exactly the seven lookup models, all owned by createdBy", () => { + it("marks exactly the eight lookup models, all owned by createdBy", () => { expect([...LOOKUP_MODELS].sort()).toEqual([ "ActivityType", "Company", "ContactRole", "JobSource", + "JobStageType", "JobTitle", "Location", "Tag", @@ -107,11 +107,41 @@ describe("backup ordering", () => { expect(MODEL_SPECS.ContactInfo.scope("u1")).toEqual({ resume: { profile: { userId: "u1" } }, }); - expect(MODEL_SPECS.Interview.scope("u1")).toEqual({ job: { userId: "u1" } }); expect(MODEL_SPECS.AutomationRun.scope("u1")).toEqual({ automation: { userId: "u1" }, }); }); + + it("has dropped the vestigial Interview model entirely", () => { + expect(MODEL_SPECS).not.toHaveProperty("Interview"); + expect(INSERT_ORDER).not.toContain("Interview" as BackupModel); + expect(MODEL_SPECS.Contact.fks).not.toHaveProperty("interviewId"); + }); + + // JobStageType is a lookup like ContactRole: without that flag a restore + // into an account that already holds the seeded types would double them. + it("treats JobStageType as a lookup and the rest as owned-through-Job", () => { + expect(MODEL_SPECS.JobStageType.lookup).toBe(true); + expect(LOOKUP_MODELS).toContain("JobStageType" as BackupModel); + expect(MODEL_SPECS.JobStage.lookup).toBeUndefined(); + expect(MODEL_SPECS.JobStage.scope("u1")).toEqual({ Job: { userId: "u1" } }); + expect(MODEL_SPECS.JobStageInterviewer.scope("u1")).toEqual({ + Stage: { Job: { userId: "u1" } }, + }); + expect(MODEL_SPECS.JobStagePrepQuestion.scope("u1")).toEqual({ + Stage: { Job: { userId: "u1" } }, + }); + }); + + it("inserts the stage models after Job, Contact and Question", () => { + const at = (m: BackupModel) => INSERT_ORDER.indexOf(m); + expect(at("JobStageType")).toBeLessThan(at("JobStage")); + expect(at("Job")).toBeLessThan(at("JobStage")); + expect(at("Contact")).toBeLessThan(at("JobStageInterviewer")); + expect(at("Question")).toBeLessThan(at("JobStagePrepQuestion")); + expect(at("JobStage")).toBeLessThan(at("JobStageInterviewer")); + expect(at("JobStage")).toBeLessThan(at("JobStagePrepQuestion")); + }); }); describe("emptiness models", () => { diff --git a/__tests__/backupSchema.spec.ts b/__tests__/backupSchema.spec.ts index 9a2031970..744e2a4c7 100644 --- a/__tests__/backupSchema.spec.ts +++ b/__tests__/backupSchema.spec.ts @@ -102,3 +102,80 @@ describe("data schema", () => { expect(parsed.jobStatuses[0].value).toBe("new"); }); }); + +describe("stage groups", () => { + // Zod strips unknown keys, so an older file carrying Interview and + // interviewId still imports — which is right for a model with zero reads. + it("silently drops a legacy Interview group and Contact.interviewId", () => { + const parsed = BackupDataSchema.parse({ + Interview: [{ id: "i1", createdAt: "2026-01-01T00:00:00.000Z", jobId: "j1" }], + Contact: [ + { + id: "c1", + name: "Pat", + email: null, + createdAt: "2026-01-01T00:00:00.000Z", + interviewId: "i1", + }, + ], + }); + expect(parsed).not.toHaveProperty("Interview"); + expect(parsed.Contact[0]).not.toHaveProperty("interviewId"); + }); + + it("defaults every stage group to an empty array", () => { + const parsed = BackupDataSchema.parse({}); + expect(parsed.JobStageType).toEqual([]); + expect(parsed.JobStage).toEqual([]); + expect(parsed.JobStageInterviewer).toEqual([]); + expect(parsed.JobStagePrepQuestion).toEqual([]); + }); + + // D3: JobStatus ids differ between installs, so the status crosses by name. + it("carries a stage type's parent status by value, not by id", () => { + const parsed = BackupDataSchema.parse({ + JobStageType: [ + { + id: "t1", + label: "Final / Onsite Interview", + value: "final / onsite interview", + statusValue: "interview", + sortOrder: 6, + }, + ], + }); + expect(parsed.JobStageType[0].statusValue).toBe("interview"); + expect(parsed.JobStageType[0]).not.toHaveProperty("statusId"); + }); + + it("rejects a stage type that carries no statusValue", () => { + expect(() => + BackupDataSchema.parse({ + JobStageType: [{ id: "t1", label: "X", value: "x", sortOrder: 0 }], + }), + ).toThrow(); + }); + + it("accepts a stage with a null occurredAt and a full stage alike", () => { + const parsed = BackupDataSchema.parse({ + JobStage: [ + { + id: "s1", + jobId: "j1", + stageTypeId: "t1", + occurredAt: null, + isCurrent: true, + outcome: null, + notes: null, + durationMins: null, + format: null, + location: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + expect(parsed.JobStage[0].occurredAt).toBeNull(); + expect(parsed.JobStage[0].isCurrent).toBe(true); + }); +}); diff --git a/src/lib/backup/export.ts b/src/lib/backup/export.ts index ebe00fbbf..0db3ea0df 100644 --- a/src/lib/backup/export.ts +++ b/src/lib/backup/export.ts @@ -9,12 +9,13 @@ import { isResumeFilePath } from "@/lib/resumeFiles"; type Row = Record; -// Job and Question need their tag ids, which are not columns; everything else -// reads as-is. Prisma's implicit m2m tables are not models, so this include is -// the only way the join rows reach the file at all. -const TAG_INCLUDE: Partial>> = { +// Job and Question need their tag ids and JobStageType needs its status value; +// none of the three is a plain column. Prisma's implicit m2m tables are not +// models, so this include is the only way the join rows reach the file at all. +const ROW_INCLUDE: Partial>> = { Job: { Status: { select: { value: true } }, tags: { select: { id: true } } }, Question: { tags: { select: { id: true } } }, + JobStageType: { Status: { select: { value: true } } }, }; function stripOwnership(model: BackupModel, row: Row): Row { @@ -32,7 +33,7 @@ export async function collectBackupData(userId: string): Promise { const delegate = (db as unknown as Record Promise }>)[ spec.delegate ]; - const include = TAG_INCLUDE[model]; + const include = ROW_INCLUDE[model]; return delegate.findMany( include ? { where: spec.scope(userId), include } : { where: spec.scope(userId) }, ); @@ -49,7 +50,12 @@ export async function collectBackupData(userId: string): Promise { select: { defaultResumeId: true }, }), db.jobStatus.findMany({ - where: { jobs: { some: { userId } } }, + // Statuses reached by a job OR by a stage type: a seeded Withdrawn stage + // type long predates any withdrawn job, and its statusValue has to + // resolve on import. + where: { + OR: [{ jobs: { some: { userId } } }, { stageTypes: { some: { createdBy: userId } } }], + }, select: { label: true, value: true }, }), ]; @@ -76,6 +82,13 @@ export async function collectBackupData(userId: string): Promise { return { ...rest, statusValue: Status.value }; } + if (model === "JobStageType") { + const { statusId, Status, ...rest } = clean as Row & { + Status: { value: string }; + }; + return { ...rest, statusValue: Status.value }; + } + if (model === "Question") { const { tags, ...rest } = clean as Row & { tags: { id: string }[] }; for (const tag of tags ?? []) { diff --git a/src/lib/backup/idmap.ts b/src/lib/backup/idmap.ts index f69019e7d..6c6014402 100644 --- a/src/lib/backup/idmap.ts +++ b/src/lib/backup/idmap.ts @@ -16,7 +16,6 @@ const OPTIONAL_FKS = new Set([ "Job.resumeId", "Job.coverLetterId", "Job.automationId", - "Contact.interviewId", "Contact.companyId", "Contact.locationId", "Contact.workedAtCompanyId", diff --git a/src/lib/backup/ordering.ts b/src/lib/backup/ordering.ts index c39709ab5..ff1e193e6 100644 --- a/src/lib/backup/ordering.ts +++ b/src/lib/backup/ordering.ts @@ -24,10 +24,13 @@ export type BackupModel = | "Automation" | "Job" | "Note" - | "Interview" | "Contact" | "ContactRole" | "JobContact" + | "JobStageType" + | "JobStage" + | "JobStageInterviewer" + | "JobStagePrepQuestion" | "Task" | "Activity" | "Question" @@ -139,12 +142,10 @@ export const MODEL_SPECS: Record = { scope: byUser, }, Note: { delegate: "note", owner: "userId", fks: { jobId: "Job" }, scope: byUser }, - Interview: { delegate: "interview", fks: { jobId: "Job" }, scope: (userId) => ({ job: { userId } }) }, Contact: { delegate: "contact", owner: "createdBy", fks: { - interviewId: "Interview", companyId: "Company", locationId: "Location", workedAtCompanyId: "Company", @@ -166,6 +167,32 @@ export const MODEL_SPECS: Record = { fks: { jobId: "Job", contactId: "Contact", roleId: "ContactRole" }, scope: (userId) => ({ Job: { userId } }), }, + // A lookup like ContactRole, so a restore into an account that already holds + // the seeded stage types does not double them. `fks` is empty on purpose: + // JobStatus is global and never a BackupModel, so statusId crosses as a + // natural key (D3), exactly as Job.statusId does. + JobStageType: { + delegate: "jobStageType", + owner: "createdBy", + lookup: true, + fks: {}, + scope: byCreatedBy, + }, + JobStage: { + delegate: "jobStage", + fks: { jobId: "Job", stageTypeId: "JobStageType" }, + scope: (userId) => ({ Job: { userId } }), + }, + JobStageInterviewer: { + delegate: "jobStageInterviewer", + fks: { stageId: "JobStage", contactId: "Contact" }, + scope: (userId) => ({ Stage: { Job: { userId } } }), + }, + JobStagePrepQuestion: { + delegate: "jobStagePrepQuestion", + fks: { stageId: "JobStage", questionId: "Question" }, + scope: (userId) => ({ Stage: { Job: { userId } } }), + }, Task: { delegate: "task", owner: "userId", @@ -210,12 +237,18 @@ export const INSERT_ORDER: BackupModel[] = [ "Automation", "Job", "Note", - "Interview", "Contact", "JobContact", + // Ahead of the stage models: JobStagePrepQuestion.questionId points here, and + // its Restrict also means the reversed DELETE_ORDER has to clear prep rows + // before questions. + "Question", + "JobStageType", + "JobStage", + "JobStageInterviewer", + "JobStagePrepQuestion", "Task", "Activity", - "Question", "AutomationRun", "UserSettings", ]; diff --git a/src/lib/backup/schema.ts b/src/lib/backup/schema.ts index fa6bb143a..9bbb2f17f 100644 --- a/src/lib/backup/schema.ts +++ b/src/lib/backup/schema.ts @@ -20,6 +20,7 @@ const dt = z.coerce.date(); const optDt = z.coerce.date().nullable(); const int = z.number().int(); const optInt = z.number().int().nullable(); +const bool = z.boolean(); // The watchlist columns are optional, not just nullable: a backup taken before // they existed has no such key, and undefined leaves Prisma on the default. @@ -223,9 +224,7 @@ const Note = z.object({ updatedAt: dt, }); -const Interview = z.object({ id, createdAt: dt, jobId: id }); - -// Everything past `interviewId` is `.optional()` as well as nullable: a backup +// Everything past `createdAt` is `.optional()` as well as nullable: a backup // taken before the contacts feature has no such key, and undefined leaves // Prisma on the column default. const Contact = z.object({ @@ -233,7 +232,6 @@ const Contact = z.object({ name: str, email: optStr, createdAt: dt, - interviewId: optId, title: optStr.optional(), phone: optStr.optional(), linkedinUrl: optStr.optional(), @@ -259,6 +257,47 @@ const JobContact = z.object({ createdAt: dt, }); +// statusValue, not statusId: JobStatus is global, never a BackupModel, and its +// ids differ between installs. Mirrors how Job crosses (D3). +const JobStageType = z.object({ + id, + label: str, + value: str, + statusValue: str, + sortOrder: int, +}); + +const JobStage = z.object({ + id, + jobId: id, + stageTypeId: id, + occurredAt: optDt, + isCurrent: bool, + outcome: optStr, + notes: optStr, + durationMins: optInt, + format: optStr, + location: optStr, + createdAt: dt, + updatedAt: dt, +}); + +const JobStageInterviewer = z.object({ + id, + stageId: id, + contactId: id, + createdAt: dt, +}); + +const JobStagePrepQuestion = z.object({ + id, + stageId: id, + questionId: id, + asked: bool, + askedAt: optDt, + createdAt: dt, +}); + const Task = z.object({ id, title: str, @@ -346,9 +385,12 @@ export const BackupDataSchema = z.object({ Automation: group(Automation), Job: group(Job), Note: group(Note), - Interview: group(Interview), Contact: group(Contact), JobContact: group(JobContact), + JobStageType: group(JobStageType), + JobStage: group(JobStage), + JobStageInterviewer: group(JobStageInterviewer), + JobStagePrepQuestion: group(JobStagePrepQuestion), Task: group(Task), Activity: group(Activity), Question: group(Question), From b574d802df6fac1fe1aed3fb8fef4703a499538f Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 09:03:41 -0600 Subject: [PATCH 03/30] feat(backup): resolve stage-type status by value on import, reseed stage types from a pre-stages file and seed them in the round-trip helper The pre-contacts reseed test now strips the stage groups too: v1.1.19 predates both features, and JobStageInterviewer.contactId is a required FK, so leaving interviewers behind aborts the import on a contact the file no longer carries. Co-Authored-By: Claude Opus 5 --- __tests__/backupRoundTrip.spec.ts | 113 +++++++++++++++++++++++++++++- __tests__/helpers/backupTestDb.ts | 21 +++++- src/lib/backup/import.ts | 43 +++++++++++- 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/__tests__/backupRoundTrip.spec.ts b/__tests__/backupRoundTrip.spec.ts index be4cb189b..e50ae4903 100644 --- a/__tests__/backupRoundTrip.spec.ts +++ b/__tests__/backupRoundTrip.spec.ts @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; import { PrismaClient } from "@prisma/client"; -import { APP_CONSTANTS, CONTACT_ROLES } from "@/lib/constants"; +import { APP_CONSTANTS, CONTACT_ROLES, JOB_STAGES } from "@/lib/constants"; import { buildBackupZip } from "@/lib/backup/export"; import { importBackup } from "@/lib/backup/import"; import { listSnapshots, readSnapshot } from "@/lib/backup/snapshot"; @@ -149,7 +149,29 @@ async function seedFullAccount() { const question = await prisma.question.create({ data: { question: "Why us?", createdBy: userId, tags: { connect: { id: tag.id } } }, }); - expect(question.id).toBeTruthy(); + + const onsiteType = await prisma.jobStageType.findFirstOrThrow({ + where: { createdBy: userId, value: "final / onsite interview" }, + }); + const stage = await prisma.jobStage.create({ + data: { + jobId: job.id, + stageTypeId: onsiteType.id, + occurredAt: new Date("2026-09-24T14:00:00.000Z"), + isCurrent: true, + outcome: "scheduled", + notes: "Bring laptop", + durationMins: 90, + format: "On-site", + location: "Acme HQ, Bldg 3", + }, + }); + await prisma.jobStageInterviewer.create({ + data: { stageId: stage.id, contactId: contact.id }, + }); + await prisma.jobStagePrepQuestion.create({ + data: { stageId: stage.id, questionId: question.id, asked: true, askedAt: new Date() }, + }); const activityType = await prisma.activityType.create({ data: { label: "Applying", value: "applying", createdBy: userId }, @@ -339,15 +361,70 @@ describe("backup round trip", () => { expect(await prisma.contactRole.count({ where: { createdBy: userId } })).toBe(5); }); + it("round-trips a job's timeline, its interviewer and its prep question", async () => { + const restored = await prisma.jobStage.findFirstOrThrow({ + where: { Job: { userId } }, + include: { + StageType: { include: { Status: true } }, + interviewers: { include: { Contact: true } }, + prepQuestions: { include: { Question: true } }, + Job: true, + }, + }); + + expect(restored.StageType.value).toBe("final / onsite interview"); + expect(restored.StageType.Status.value).toBe("interview"); + expect(restored.isCurrent).toBe(true); + expect(restored.durationMins).toBe(90); + expect(restored.location).toBe("Acme HQ, Bldg 3"); + expect(restored.Job.userId).toBe(userId); + expect(restored.interviewers[0].Contact.name).toBe("Pat Lee"); + expect(restored.prepQuestions[0].asked).toBe(true); + expect(restored.prepQuestions[0].Question.question).toBe("Why us?"); + + // Lookup, like ContactRole: the seeded types came back once, not doubled. + expect( + await prisma.jobStageType.count({ where: { createdBy: userId } }), + ).toBe(14); + }); + + // D3: the file carries no JobStatus ids, so every restored type has to have + // been re-pointed at this database's own status rows. + it("re-points every restored stage type at a real local status", async () => { + const types = await prisma.jobStageType.findMany({ + where: { createdBy: userId }, + include: { Status: true }, + }); + expect(types).toHaveLength(14); + for (const type of types) { + expect(type.Status).not.toBeNull(); + } + const withdrawn = types.find((t) => t.value === "withdrawn")!; + // Withdrawn is the case the export's widened jobStatuses query exists for: + // no job holds it, so it would otherwise never reach the file. + expect(withdrawn.Status.value).toBe("withdrawn"); + }); + it("reseeds the default contact roles when restoring a pre-contacts backup", async () => { const { buffer } = await buildBackupZip(userId, "owner@example.com"); // Reshape into what v1.1.19 wrote: no contact groups, no counts for them. + // The stage groups go too — that release predates stages as well, and + // JobStageInterviewer.contactId is a required FK, so leaving it behind + // would abort the import on a contact the file no longer carries. const JSZip = (await import("jszip")).default; const zip = await JSZip.loadAsync(buffer); const data = JSON.parse(await zip.file("data.json")!.async("string")); const manifest = JSON.parse(await zip.file("manifest.json")!.async("string")); - for (const group of ["Contact", "ContactRole", "JobContact"]) { + for (const group of [ + "Contact", + "ContactRole", + "JobContact", + "JobStageType", + "JobStage", + "JobStageInterviewer", + "JobStagePrepQuestion", + ]) { delete data[group]; delete manifest.counts[group]; } @@ -363,6 +440,36 @@ describe("backup round trip", () => { ); }, 120_000); + it("reseeds the default stage types when restoring a pre-stages backup", async () => { + const { buffer } = await buildBackupZip(userId, "owner@example.com"); + + const JSZip = (await import("jszip")).default; + const zip = await JSZip.loadAsync(buffer); + const data = JSON.parse(await zip.file("data.json")!.async("string")); + const manifest = JSON.parse(await zip.file("manifest.json")!.async("string")); + for (const group of [ + "JobStageType", + "JobStage", + "JobStageInterviewer", + "JobStagePrepQuestion", + ]) { + delete data[group]; + delete manifest.counts[group]; + } + zip.file("data.json", JSON.stringify(data)); + zip.file("manifest.json", JSON.stringify(manifest)); + const legacy = await zip.generateAsync({ type: "nodebuffer" }); + + await importBackup(legacy, userId, "owner@example.com", true); + + const types = await prisma.jobStageType.findMany({ where: { createdBy: userId } }); + expect(types.map((t) => t.value).sort()).toEqual( + JOB_STAGES.map((s) => s.value).sort(), + ); + // Decision 22: restored jobs come back stageless, and stay that way. + expect(await prisma.jobStage.count({ where: { Job: { userId } } })).toBe(0); + }, 120_000); + it("does not demand confirmWipe on a freshly signed-up account", async () => { const freshUserId = await seedAccount(prisma, "fresh@example.com"); const { buffer } = await buildBackupZip(userId, "owner@example.com"); diff --git a/__tests__/helpers/backupTestDb.ts b/__tests__/helpers/backupTestDb.ts index 2ab64814b..4838c1ff0 100644 --- a/__tests__/helpers/backupTestDb.ts +++ b/__tests__/helpers/backupTestDb.ts @@ -4,7 +4,12 @@ import path from "path"; import { execFileSync } from "child_process"; import { randomUUID } from "crypto"; import type { PrismaClient } from "@prisma/client"; -import { CONTACT_ROLES, JOB_SOURCES, JOB_STATUSES } from "@/lib/constants"; +import { + CONTACT_ROLES, + JOB_SOURCES, + JOB_STAGES, + JOB_STATUSES, +} from "@/lib/constants"; export function makeTestDbUrl(): { url: string; dir: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "jobsync-backup-")); @@ -53,5 +58,19 @@ export async function seedAccount( })), }); + const statusRows = await prisma.jobStatus.findMany({ + select: { id: true, value: true }, + }); + const statusIdByValue = new Map(statusRows.map((s) => [s.value, s.id])); + await prisma.jobStageType.createMany({ + data: JOB_STAGES.map((stage) => ({ + label: stage.label, + value: stage.value, + statusId: statusIdByValue.get(stage.status)!, + sortOrder: stage.sortOrder, + createdBy: user.id, + })), + }); + return user.id; } diff --git a/src/lib/backup/import.ts b/src/lib/backup/import.ts index 2f53db787..bafcbb95f 100644 --- a/src/lib/backup/import.ts +++ b/src/lib/backup/import.ts @@ -2,7 +2,7 @@ import fs from "fs/promises"; import path from "path"; import type JSZip from "jszip"; import db from "@/lib/db"; -import { APP_CONSTANTS, CONTACT_ROLES } from "@/lib/constants"; +import { APP_CONSTANTS, CONTACT_ROLES, JOB_STAGES } from "@/lib/constants"; import { IdMap, buildCreateData } from "./idmap"; import { BackupError, openBackupZip, readManifest } from "./manifest"; import { @@ -147,12 +147,32 @@ async function insertLookups( data: BackupData, idMap: IdMap, userId: string, + statusByValue: Map, ): Promise { for (const model of LOOKUP_MODELS) { const spec = MODEL_SPECS[model]; for (const row of (data as unknown as Record)[model]) { const newId = idMap.mint(row.id); const { id: _old, ...rest } = row; + + // The only lookup model with a foreign key. JobStatus is global and its + // ids differ between installs, so the file carries the value (D3). + if (model === "JobStageType") { + const { statusValue, ...typeRest } = rest as typeof rest & { statusValue: string }; + const statusId = statusByValue.get(statusValue); + // Refusing beats defaulting: a wrong parent status would put the + // derived-status invariant permanently out of step on every job. + if (!statusId) { + throw new BackupError( + `Stage type "${row.value}" names status "${statusValue}", which is not in the backup.`, + ); + } + await tx[spec.delegate].create({ + data: { ...typeRest, id: newId, statusId, createdBy: userId }, + }); + continue; + } + await tx[spec.delegate].create({ data: { ...rest, id: newId, createdBy: userId }, }); @@ -306,7 +326,7 @@ export async function importBackup( const tx = transaction as unknown as Tx; await wipe(tx, userId); - await insertLookups(tx, data, idMap, userId); + await insertLookups(tx, data, idMap, userId, statusByValue); // A backup from before contacts carries no roles, and the wipe above // just deleted the seeded ones — reseed rather than leave none. @@ -320,6 +340,25 @@ export async function importBackup( }); } + // A backup from before stages carries no stage types, and the wipe + // above just deleted the seeded ones — reseed rather than leave none, + // or Add Stage and every status dropdown has nothing to resolve. + if (manifest.counts.JobStageType === undefined && data.JobStageType.length === 0) { + const stageStatuses = (await tx.jobStatus.findMany({ + select: { id: true, value: true }, + })) as unknown as { id: string; value: string }[]; + const stageStatusIds = new Map(stageStatuses.map((s) => [s.value, s.id])); + await tx.jobStageType.createMany({ + data: JOB_STAGES.map((stage) => ({ + label: stage.label, + value: stage.value, + statusId: stageStatusIds.get(stage.status)!, + sortOrder: stage.sortOrder, + createdBy: userId, + })), + }); + } + for (const model of INSERT_ORDER) { if (MODEL_SPECS[model].lookup) continue; const spec = MODEL_SPECS[model]; From e633e53247967492c6a23c290ad48c2a364a0925 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 09:07:00 -0600 Subject: [PATCH 04/30] feat(stages): add the stage model types, form schema and the shared ordering/invariant helpers Co-Authored-By: Claude Opus 5 --- __tests__/jobStageShared.spec.ts | 89 ++++++++++++++++ src/actions/jobStage/shared.ts | 168 +++++++++++++++++++++++++++++++ src/lib/jobs/sortStages.ts | 19 ++++ src/models/jobStage.model.ts | 82 +++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 __tests__/jobStageShared.spec.ts create mode 100644 src/actions/jobStage/shared.ts create mode 100644 src/lib/jobs/sortStages.ts create mode 100644 src/models/jobStage.model.ts diff --git a/__tests__/jobStageShared.spec.ts b/__tests__/jobStageShared.spec.ts new file mode 100644 index 000000000..3a2b28d52 --- /dev/null +++ b/__tests__/jobStageShared.spec.ts @@ -0,0 +1,89 @@ +import { sortStages } from "@/lib/jobs/sortStages"; +import { jobFieldsForStage } from "@/actions/jobStage/shared"; + +const at = (iso: string) => new Date(iso); + +describe("sortStages", () => { + // SQLite sorts NULL first on ASC and Prisma's nulls:"last" is unsupported + // there, so ordering lives here and must not migrate into the query. + it("orders by occurredAt ascending with undated stages last", () => { + const stages = [ + { id: "c", occurredAt: null, createdAt: at("2026-01-01T00:00:00Z") }, + { id: "b", occurredAt: at("2026-09-10T00:00:00Z"), createdAt: at("2026-01-01T00:00:00Z") }, + { id: "a", occurredAt: at("2026-09-01T00:00:00Z"), createdAt: at("2026-01-01T00:00:00Z") }, + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual(["a", "b", "c"]); + }); + + it("breaks ties on createdAt", () => { + const same = at("2026-09-10T00:00:00Z"); + const stages = [ + { id: "second", occurredAt: same, createdAt: at("2026-02-01T00:00:00Z") }, + { id: "first", occurredAt: same, createdAt: at("2026-01-01T00:00:00Z") }, + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual(["first", "second"]); + }); + + it("orders several undated stages by createdAt", () => { + const stages = [ + { id: "later", occurredAt: null, createdAt: at("2026-03-01T00:00:00Z") }, + { id: "earlier", occurredAt: null, createdAt: at("2026-01-01T00:00:00Z") }, + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual(["earlier", "later"]); + }); + + it("slots a backdated stage into the middle rather than rejecting it", () => { + const stages = [ + { id: "new", occurredAt: at("2026-09-01T00:00:00Z"), createdAt: at("2026-09-01T00:00:00Z") }, + { id: "onsite", occurredAt: at("2026-09-24T00:00:00Z"), createdAt: at("2026-09-02T00:00:00Z") }, + { id: "backdated", occurredAt: at("2026-09-10T00:00:00Z"), createdAt: at("2026-09-30T00:00:00Z") }, + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual(["new", "backdated", "onsite"]); + }); + + it("does not mutate its input", () => { + const stages = [ + { id: "b", occurredAt: at("2026-09-10T00:00:00Z"), createdAt: at("2026-01-01T00:00:00Z") }, + { id: "a", occurredAt: at("2026-09-01T00:00:00Z"), createdAt: at("2026-01-01T00:00:00Z") }, + ]; + sortStages(stages); + expect(stages.map((s) => s.id)).toEqual(["b", "a"]); + }); +}); + +describe("jobFieldsForStage", () => { + it("writes only the status for a non-applied, non-interview stage", () => { + expect(jobFieldsForStage("rejected", "s-rej", null, null)).toEqual({ + statusId: "s-rej", + }); + }); + + it("marks an interview stage as applied without touching appliedDate", () => { + expect(jobFieldsForStage("interview", "s-int", null, null)).toEqual({ + statusId: "s-int", + applied: true, + }); + }); + + it("sets appliedDate from the stage's own date when it has one", () => { + const occurred = at("2026-09-03T00:00:00Z"); + expect(jobFieldsForStage("applied", "s-app", occurred, null)).toEqual({ + statusId: "s-app", + applied: true, + appliedDate: occurred, + }); + }); + + it("falls back to now for an undated applied stage", () => { + const before = Date.now(); + const data = jobFieldsForStage("applied", "s-app", null, null) as any; + expect(data.appliedDate.getTime()).toBeGreaterThanOrEqual(before); + }); + + it("never overwrites an appliedDate already on the job", () => { + const existing = at("2026-08-01T00:00:00Z"); + expect( + jobFieldsForStage("applied", "s-app", at("2026-09-03T00:00:00Z"), existing), + ).toEqual({ statusId: "s-app", applied: true }); + }); +}); diff --git a/src/actions/jobStage/shared.ts b/src/actions/jobStage/shared.ts new file mode 100644 index 000000000..2ec951c1a --- /dev/null +++ b/src/actions/jobStage/shared.ts @@ -0,0 +1,168 @@ +import prisma from "@/lib/db"; + +export { sortStages } from "@/lib/jobs/sortStages"; + +// Not a "use server" module: queries.ts, mutations.ts, interviewers.ts and +// prep.ts all use these, and exporting them from any of those would make each +// one a callable server-action endpoint. + +export const STAGE_DETAIL_INCLUDE = { + StageType: { include: { Status: true } }, + interviewers: { + include: { + Contact: { + select: { + id: true, + name: true, + title: true, + email: true, + phone: true, + linkedinUrl: true, + Company: { select: { id: true, label: true } }, + }, + }, + }, + orderBy: { createdAt: "asc" as const }, + }, + prepQuestions: { + include: { + Question: { select: { id: true, question: true, tags: true } }, + }, + orderBy: { createdAt: "asc" as const }, + }, +}; + +// The applied/appliedDate rules updateJobStatus has always used, lifted out so +// every stage write path keeps the dashboard and CSV export in agreement. +export function jobFieldsForStage( + statusValue: string, + statusId: string, + occurredAt: Date | null, + existingAppliedDate: Date | null, +): Record { + const data: Record = { statusId }; + if (statusValue === "applied" || statusValue === "interview") { + data.applied = true; + } + if (statusValue === "applied" && !existingAppliedDate) { + data.appliedDate = occurredAt ?? new Date(); + } + return data; +} + +export type StageOwnerRow = { + id: string; + jobId: string; + occurredAt: Date | null; + isCurrent: boolean; + StageType: { id: string; label: string; statusId: string; Status: { value: string } }; +}; + +// JobStage has no ownership column, so the chain is the only check there is. +export async function assertStageOwned( + stageId: string, + userId: string, +): Promise { + const stage = await prisma.jobStage.findFirst({ + where: { id: stageId, Job: { userId } }, + select: { + id: true, + jobId: true, + occurredAt: true, + isCurrent: true, + StageType: { + select: { + id: true, + label: true, + statusId: true, + Status: { select: { value: true } }, + }, + }, + }, + }); + if (!stage) throw new Error("Stage not found"); + return stage; +} + +// The gate is on stage KIND and never on stage timing: preparing for an +// interview a week out is the primary case (spec decision 10). +export function assertInterviewStage(stage: StageOwnerRow): void { + if (stage.StageType.Status.value !== "interview") { + throw new Error( + `"${stage.StageType.label}" is not an interview stage, so it has no interviewers or prep list.`, + ); + } +} + +type StageTx = { + jobStage: { + updateMany: (args: any) => Promise; + findFirst: (args: any) => Promise; + }; + job: { + findFirst: (args: any) => Promise; + update: (args: any) => Promise; + }; +}; + +// Clear the old current row BEFORE setting the new one: the partial unique +// index JobStage_jobId_current_key refuses two live rows for one job. +// +// userId is in every where clause (D8). Callers check ownership first, but a +// helper that finds a row by id alone stays safe only until someone adds a +// caller that forgets, and CLAUDE.md's rule has no "the caller checked" +// exemption. updateMany, not update: a relation filter is not allowed in a +// single-row update's where. +export async function promoteStage( + tx: StageTx, + jobId: string, + stageId: string, + userId: string, +): Promise { + await tx.jobStage.updateMany({ + where: { jobId, isCurrent: true, NOT: { id: stageId }, Job: { userId } }, + data: { isCurrent: false }, + }); + const promoted = await tx.jobStage.updateMany({ + where: { id: stageId, Job: { userId } }, + data: { isCurrent: true }, + }); + if (promoted.count === 0) throw new Error("Stage not found"); +} + +// The derived-status invariant: Job.statusId always equals the current +// stage's type's statusId whenever a current stage exists. +export async function syncJobFromCurrentStage( + tx: StageTx, + jobId: string, + stageId: string, + userId: string, +): Promise { + const stage = await tx.jobStage.findFirst({ + where: { id: stageId, Job: { userId } }, + select: { + occurredAt: true, + StageType: { + select: { statusId: true, Status: { select: { value: true } } }, + }, + }, + }); + if (!stage) throw new Error("Stage not found"); + + const job = await tx.job.findFirst({ + where: { id: jobId, userId }, + select: { appliedDate: true }, + }); + + // Prisma allows a non-unique field beside id in an update's where — + // deleteJobById and updateJob both already do this. + await tx.job.update({ + where: { id: jobId, userId }, + data: jobFieldsForStage( + stage.StageType.Status.value, + stage.StageType.statusId, + stage.occurredAt, + job?.appliedDate ?? null, + ), + }); +} diff --git a/src/lib/jobs/sortStages.ts b/src/lib/jobs/sortStages.ts new file mode 100644 index 000000000..9c0444d14 --- /dev/null +++ b/src/lib/jobs/sortStages.ts @@ -0,0 +1,19 @@ +// SQLite sorts NULL first on ASC and Prisma's nulls:"last" is unsupported on +// that connector, so undated-stages-last cannot live in the query. Both the +// server actions and the Timeline tab sort through this one comparator: the +// job-details page loads stages with no orderBy at all. +export function sortStages< + T extends { occurredAt: Date | null; createdAt: Date }, +>(stages: T[]): T[] { + return [...stages].sort((a, b) => { + if (a.occurredAt && b.occurredAt) { + const diff = a.occurredAt.getTime() - b.occurredAt.getTime(); + if (diff !== 0) return diff; + } else if (a.occurredAt) { + return -1; + } else if (b.occurredAt) { + return 1; + } + return a.createdAt.getTime() - b.createdAt.getTime(); + }); +} diff --git a/src/models/jobStage.model.ts b/src/models/jobStage.model.ts new file mode 100644 index 000000000..78019d2bb --- /dev/null +++ b/src/models/jobStage.model.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import type { Tag } from "./job.model"; +import type { ContactSummary } from "./contact.model"; +import { STAGE_OUTCOMES } from "@/lib/constants"; + +export interface JobStageTypeRef { + id: string; + label: string; + value: string; + statusId: string; + sortOrder: number; + Status?: { id: string; label: string; value: string }; + _count?: { stages: number }; +} + +export interface JobStageInterviewerLink { + id: string; + stageId: string; + contactId: string; + Contact: ContactSummary; +} + +export interface JobStagePrepQuestionLink { + id: string; + stageId: string; + questionId: string; + asked: boolean; + askedAt: Date | null; + Question: { id: string; question: string; tags: Tag[] }; +} + +export interface JobStage { + id: string; + jobId: string; + stageTypeId: string; + occurredAt: Date | null; + isCurrent: boolean; + outcome: string | null; + notes: string | null; + durationMins: number | null; + format: string | null; + location: string | null; + createdAt: Date; + updatedAt: Date; + StageType: JobStageTypeRef; + interviewers: JobStageInterviewerLink[]; + prepQuestions: JobStagePrepQuestionLink[]; +} + +const outcomeValues = STAGE_OUTCOMES.map((o) => o.value) as unknown as [ + string, + ...string[], +]; + +// Either an existing type id or a custom name, never neither. A custom name +// also needs its parent status, which the dialog asks for in the same pass. +export const AddJobStageFormSchema = z + .object({ + id: z.string().optional(), + jobId: z.string().min(1), + stageTypeId: z.string().optional(), + customLabel: z.string().max(80).optional(), + customStatusId: z.string().optional(), + date: z.date().nullable().optional(), + time: z.string().optional(), + notes: z.string().max(2000).optional(), + outcome: z.enum(outcomeValues).nullable().optional(), + durationMins: z.number().int().positive().max(1440).nullable().optional(), + format: z.string().max(60).optional(), + location: z.string().max(200).optional(), + setAsCurrent: z.boolean().default(true), + }) + .refine((data) => !!data.stageTypeId || !!data.customLabel?.trim(), { + message: "Pick a stage or enter a custom stage name.", + path: ["stageTypeId"], + }) + .refine( + (data) => !data.customLabel?.trim() || !!data.customStatusId, + { message: "Pick the status this custom stage means.", path: ["customStatusId"] }, + ); + +export type AddJobStageValues = z.infer; From eef761318b084c3574e96eeb07e8d15690ad47e5 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 09:09:01 -0600 Subject: [PATCH 05/30] feat(stages): add job-stage read, add, edit, delete and promote actions behind a barrel Co-Authored-By: Claude Opus 5 --- __tests__/jobStage.actions.spec.ts | 378 +++++++++++++++++++++++++++++ src/actions/jobStage.actions.ts | 10 + src/actions/jobStage/mutations.ts | 209 ++++++++++++++++ src/actions/jobStage/queries.ts | 20 ++ 4 files changed, 617 insertions(+) create mode 100644 __tests__/jobStage.actions.spec.ts create mode 100644 src/actions/jobStage.actions.ts create mode 100644 src/actions/jobStage/mutations.ts create mode 100644 src/actions/jobStage/queries.ts diff --git a/__tests__/jobStage.actions.spec.ts b/__tests__/jobStage.actions.spec.ts new file mode 100644 index 000000000..8849a8bdb --- /dev/null +++ b/__tests__/jobStage.actions.spec.ts @@ -0,0 +1,378 @@ +import { + getJobStages, + addJobStage, + deleteJobStage, + setCurrentJobStage, + setStageNotes, +} from "@/actions/jobStage.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; +import { resolveJobStageType } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => { + const jobStage = { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), + }; + const job = { count: vi.fn(), findFirst: vi.fn(), update: vi.fn() }; + const jobStageType = { findFirst: vi.fn() }; + const client = { + jobStage, + job, + jobStageType, + $transaction: vi.fn(async (fn: any) => fn(client)), + }; + return { default: client }; +}); + +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ + resolveJobStageType: vi.fn(), + resolveStageTypeForStatusId: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; +const at = (iso: string) => new Date(iso); + +const interviewType = { + id: "t-int", + label: "Final / Onsite Interview", + statusId: "s-int", + Status: { value: "interview" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + db.job.count.mockResolvedValue(1); + db.job.findFirst.mockResolvedValue({ appliedDate: null }); + db.jobStage.updateMany.mockResolvedValue({ count: 1 }); +}); + +describe("getJobStages", () => { + it("scopes the read through the job's owner and sorts undated stages last", async () => { + db.jobStage.findMany.mockResolvedValue([ + { id: "c", occurredAt: null, createdAt: at("2026-01-01T00:00:00Z") }, + { id: "a", occurredAt: at("2026-09-01T00:00:00Z"), createdAt: at("2026-01-01T00:00:00Z") }, + ]); + + const stages = await getJobStages("j1"); + + expect(db.jobStage.findMany.mock.calls[0][0].where).toEqual({ + jobId: "j1", + Job: { userId: user.id }, + }); + expect(stages.map((s: any) => s.id)).toEqual(["a", "c"]); + }); +}); + +describe("addJobStage", () => { + it("refuses a job the caller does not own", async () => { + db.job.count.mockResolvedValue(0); + + const res = await addJobStage({ + jobId: "someone-elses", + stageTypeId: "t-int", + setAsCurrent: true, + } as any); + + expect(res.success).toBe(false); + expect(db.jobStage.create).not.toHaveBeenCalled(); + }); + + it("refuses a stage type the caller does not own", async () => { + db.jobStageType.findFirst.mockResolvedValue(null); + + const res = await addJobStage({ + jobId: "j1", + stageTypeId: "someone-elses-type", + setAsCurrent: true, + } as any); + + expect(res.success).toBe(false); + expect(db.jobStage.create).not.toHaveBeenCalled(); + }); + + it("creates a custom type through the resolver and uses its id", async () => { + (resolveJobStageType as any).mockResolvedValue({ + id: "t-new", + label: "Panel Interview", + created: true, + }); + db.jobStage.create.mockResolvedValue({ id: "st1" }); + db.jobStage.findFirst.mockResolvedValue({ + id: "st1", + occurredAt: null, + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + const res = await addJobStage({ + jobId: "j1", + customLabel: "Panel Interview", + customStatusId: "s-int", + setAsCurrent: true, + } as any); + + expect(resolveJobStageType).toHaveBeenCalledWith( + "Panel Interview", + user.id, + "s-int", + ); + expect(res.success).toBe(true); + expect(db.jobStage.create.mock.calls[0][0].data.stageTypeId).toBe("t-new"); + }); + + it("clears the old current stage and rewrites the job status when set as current", async () => { + db.jobStageType.findFirst.mockResolvedValue(interviewType); + db.jobStage.create.mockResolvedValue({ id: "st2" }); + db.jobStage.findFirst.mockResolvedValue({ + id: "st2", + occurredAt: null, + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + const res = await addJobStage({ + jobId: "j1", + stageTypeId: "t-int", + setAsCurrent: true, + } as any); + + expect(res.success).toBe(true); + // D8: promoteStage names the owner in both of its updateMany calls. + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { + jobId: "j1", + isCurrent: true, + NOT: { id: "st2" }, + Job: { userId: user.id }, + }, + data: { isCurrent: false }, + }); + expect(db.job.update.mock.calls[0][0].data).toEqual({ + statusId: "s-int", + applied: true, + }); + }); + + it("leaves the current stage and the job status alone when not set as current", async () => { + db.jobStageType.findFirst.mockResolvedValue({ + id: "t-app", + label: "Applied", + statusId: "s-app", + Status: { value: "applied" }, + }); + db.jobStage.create.mockResolvedValue({ id: "st3" }); + db.jobStage.findFirst.mockResolvedValue({ id: "st3" }); + + const res = await addJobStage({ + jobId: "j1", + stageTypeId: "t-app", + setAsCurrent: false, + } as any); + + expect(res.success).toBe(true); + expect(db.jobStage.updateMany).not.toHaveBeenCalled(); + expect(db.job.update).not.toHaveBeenCalled(); + }); + + it("sets appliedDate from a backdated applied stage", async () => { + const backdated = at("2026-08-12T00:00:00Z"); + db.jobStageType.findFirst.mockResolvedValue({ + id: "t-app", + label: "Applied", + statusId: "s-app", + Status: { value: "applied" }, + }); + db.jobStage.create.mockResolvedValue({ id: "st4" }); + db.jobStage.findFirst.mockResolvedValue({ + id: "st4", + occurredAt: backdated, + StageType: { statusId: "s-app", Status: { value: "applied" } }, + }); + + await addJobStage({ + jobId: "j1", + stageTypeId: "t-app", + date: backdated, + setAsCurrent: true, + } as any); + + expect(db.job.update.mock.calls[0][0].data.appliedDate).toEqual(backdated); + }); +}); + +describe("deleteJobStage", () => { + it("promotes the previous stage and rewrites the status when deleting the current one", async () => { + db.jobStage.findFirst + // assertStageOwned + .mockResolvedValueOnce({ + id: "st-cur", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: interviewType, + }) + // syncJobFromCurrentStage's own scoped read + .mockResolvedValue({ + occurredAt: at("2026-09-03T00:00:00Z"), + StageType: { statusId: "s-app", Status: { value: "applied" } }, + }); + db.jobStage.findMany.mockResolvedValue([ + { id: "st-prev", occurredAt: at("2026-09-03T00:00:00Z"), createdAt: at("2026-09-03T00:00:00Z") }, + ]); + + const res = await deleteJobStage("st-cur"); + + expect(res.success).toBe(true); + expect(db.jobStage.delete).toHaveBeenCalledWith({ where: { id: "st-cur" } }); + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { id: "st-prev", Job: { userId: user.id } }, + data: { isCurrent: true }, + }); + expect(db.job.update.mock.calls[0][0].data.statusId).toBe("s-app"); + }); + + // Undated stages sort last, so the tail of the sorted list is the wrong + // successor whenever the job holds a dateless stage. + it("promotes the most recent dated stage, not a dateless one", async () => { + db.jobStage.findFirst + .mockResolvedValueOnce({ + id: "st-cur", + jobId: "j1", + occurredAt: at("2026-10-01T00:00:00Z"), + isCurrent: true, + StageType: interviewType, + }) + .mockResolvedValue({ + occurredAt: at("2026-09-03T00:00:00Z"), + StageType: { statusId: "s-app", Status: { value: "applied" } }, + }); + db.jobStage.findMany.mockResolvedValue([ + { id: "st-applied", occurredAt: at("2026-09-03T00:00:00Z"), createdAt: at("2026-09-03T00:00:00Z") }, + { id: "st-undated", occurredAt: null, createdAt: at("2026-09-20T00:00:00Z") }, + ]); + + await deleteJobStage("st-cur"); + + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { id: "st-applied", Job: { userId: user.id } }, + data: { isCurrent: true }, + }); + }); + + it("leaves the status untouched when the deleted stage was the only one", async () => { + db.jobStage.findFirst.mockResolvedValueOnce({ + id: "st-only", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: interviewType, + }); + db.jobStage.findMany.mockResolvedValue([]); + + const res = await deleteJobStage("st-only"); + + expect(res.success).toBe(true); + expect(db.job.update).not.toHaveBeenCalled(); + }); + + it("leaves the status untouched when deleting a non-current stage", async () => { + db.jobStage.findFirst.mockResolvedValueOnce({ + id: "st-old", + jobId: "j1", + occurredAt: at("2026-09-01T00:00:00Z"), + isCurrent: false, + StageType: interviewType, + }); + + const res = await deleteJobStage("st-old"); + + expect(res.success).toBe(true); + expect(db.jobStage.findMany).not.toHaveBeenCalled(); + expect(db.job.update).not.toHaveBeenCalled(); + }); + + it("refuses a stage the caller does not own", async () => { + db.jobStage.findFirst.mockResolvedValueOnce(null); + + const res = await deleteJobStage("someone-elses"); + + expect(res.success).toBe(false); + expect(db.jobStage.delete).not.toHaveBeenCalled(); + }); +}); + +describe("setCurrentJobStage", () => { + it("promotes the stage and rewrites the job status from its type", async () => { + db.jobStage.findFirst + // assertStageOwned + .mockResolvedValueOnce({ + id: "st9", + jobId: "j1", + occurredAt: null, + isCurrent: false, + StageType: interviewType, + }) + // syncJobFromCurrentStage's own scoped read + .mockResolvedValue({ + occurredAt: null, + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + const res = await setCurrentJobStage("st9"); + + expect(res.success).toBe(true); + // D8: every stage write names the owner, so this is updateMany with the + // ownership chain, not a bare update by id. + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { id: "st9", Job: { userId: user.id } }, + data: { isCurrent: true }, + }); + expect(db.job.update.mock.calls[0][0].where).toEqual({ + id: "j1", + userId: user.id, + }); + expect(db.job.update.mock.calls[0][0].data.statusId).toBe("s-int"); + }); +}); + +describe("setStageNotes", () => { + it("saves a note through both the ownership check and the scoped write", async () => { + db.jobStage.findFirst.mockResolvedValueOnce({ + id: "st1", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: interviewType, + }); + + const res = await setStageNotes("st1", " They pushed hard on caching. "); + + expect(res.success).toBe(true); + const call = db.jobStage.updateMany.mock.calls.at(-1)[0]; + expect(call.where).toEqual({ id: "st1", Job: { userId: user.id } }); + expect(call.data).toEqual({ notes: "They pushed hard on caching." }); + }); + + it("stores an emptied note as null rather than an empty string", async () => { + db.jobStage.findFirst.mockResolvedValueOnce({ + id: "st1", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: interviewType, + }); + + await setStageNotes("st1", " "); + + expect(db.jobStage.updateMany.mock.calls.at(-1)[0].data).toEqual({ + notes: null, + }); + }); +}); diff --git a/src/actions/jobStage.actions.ts b/src/actions/jobStage.actions.ts new file mode 100644 index 000000000..cf0170e07 --- /dev/null +++ b/src/actions/jobStage.actions.ts @@ -0,0 +1,10 @@ +// Barrel only: each module below carries its own "use server". Explicit named +// re-exports, because `export *` is not reliable from a "use server" file. +export { getJobStages } from "./jobStage/queries"; +export { + addJobStage, + updateJobStage, + deleteJobStage, + setCurrentJobStage, + setStageNotes, +} from "./jobStage/mutations"; diff --git a/src/actions/jobStage/mutations.ts b/src/actions/jobStage/mutations.ts new file mode 100644 index 000000000..829161325 --- /dev/null +++ b/src/actions/jobStage/mutations.ts @@ -0,0 +1,209 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError, combineDateAndTime } from "@/lib/utils"; +import { revalidatePath } from "next/cache"; +import { requireUser } from "../shared"; +import { resolveJobStageType } from "@/lib/jobs/resolve"; +import { + AddJobStageFormSchema, + type AddJobStageValues, +} from "@/models/jobStage.model"; +import { + STAGE_DETAIL_INCLUDE, + assertStageOwned, + promoteStage, + sortStages, + syncJobFromCurrentStage, +} from "./shared"; + +// Either the picked type (checked for ownership) or a resolved custom name. +const resolveStageTypeId = async ( + values: AddJobStageValues, + userId: string, +): Promise => { + const custom = values.customLabel?.trim(); + if (custom) { + const resolved = await resolveJobStageType( + custom, + userId, + values.customStatusId!, + ); + return resolved.id; + } + const owned = await prisma.jobStageType.findFirst({ + where: { id: values.stageTypeId!, createdBy: userId }, + select: { id: true }, + }); + if (!owned) throw new Error("Stage type not found"); + return owned.id; +}; + +const stageDataFrom = (values: AddJobStageValues, stageTypeId: string) => ({ + stageTypeId, + occurredAt: values.date + ? values.time + ? combineDateAndTime(values.date, values.time) + : values.date + : null, + outcome: values.outcome ?? null, + notes: values.notes?.trim() || null, + durationMins: values.durationMins ?? null, + format: values.format?.trim() || null, + location: values.location?.trim() || null, +}); + +export const addJobStage = async ( + values: AddJobStageValues, +): Promise => { + try { + const user = await requireUser(); + const parsed = AddJobStageFormSchema.parse(values); + + const job = await prisma.job.count({ + where: { id: parsed.jobId, userId: user.id }, + }); + if (job === 0) throw new Error("Job not found"); + + const stageTypeId = await resolveStageTypeId(parsed, user.id); + + const created = await prisma.$transaction(async (tx: any) => { + const stage = await tx.jobStage.create({ + data: { jobId: parsed.jobId, ...stageDataFrom(parsed, stageTypeId) }, + }); + if (parsed.setAsCurrent) { + await promoteStage(tx, parsed.jobId, stage.id, user.id); + await syncJobFromCurrentStage(tx, parsed.jobId, stage.id, user.id); + } + return tx.jobStage.findFirst({ + where: { id: stage.id }, + include: STAGE_DETAIL_INCLUDE, + }); + }); + + revalidatePath("/dashboard"); + return { success: true, data: created }; + } catch (error) { + return handleError(error, "Failed to add stage."); + } +}; + +export const updateJobStage = async ( + values: AddJobStageValues, +): Promise => { + try { + const user = await requireUser(); + if (!values.id) throw new Error("Stage id is required"); + const parsed = AddJobStageFormSchema.parse(values); + + const existing = await assertStageOwned(values.id, user.id); + const stageTypeId = await resolveStageTypeId(parsed, user.id); + + const updated = await prisma.$transaction(async (tx: any) => { + await tx.jobStage.update({ + where: { id: values.id }, + data: stageDataFrom(parsed, stageTypeId), + }); + if (parsed.setAsCurrent) { + await promoteStage(tx, existing.jobId, values.id!, user.id); + } + // A current stage whose type or date changed re-derives the job's + // status, so editing the type of the current stage is a status change. + if (parsed.setAsCurrent || existing.isCurrent) { + await syncJobFromCurrentStage(tx, existing.jobId, values.id!, user.id); + } + return tx.jobStage.findFirst({ + where: { id: values.id }, + include: STAGE_DETAIL_INCLUDE, + }); + }); + + revalidatePath("/dashboard"); + return { success: true, data: updated }; + } catch (error) { + return handleError(error, "Failed to update stage."); + } +}; + +export const deleteJobStage = async ( + stageId: string, +): Promise => { + try { + const user = await requireUser(); + const stage = await assertStageOwned(stageId, user.id); + + await prisma.$transaction(async (tx: any) => { + await tx.jobStage.delete({ where: { id: stageId } }); + if (!stage.isCurrent) return; + + // Deleting the current stage promotes the most recent DATED stage; with + // no stages left the job keeps its status and has no current stage. + // Undated stages sort last (D2), so simply taking the tail would promote + // a dateless stage over a recent real one and set the wrong status. + const remaining = sortStages<{ + id: string; + occurredAt: Date | null; + createdAt: Date; + }>( + await tx.jobStage.findMany({ + where: { jobId: stage.jobId }, + select: { id: true, occurredAt: true, createdAt: true }, + }), + ); + const dated = remaining.filter((s) => s.occurredAt); + const successor = dated[dated.length - 1] ?? remaining[remaining.length - 1]; + if (!successor) return; + + await promoteStage(tx, stage.jobId, successor.id, user.id); + await syncJobFromCurrentStage(tx, stage.jobId, successor.id, user.id); + }); + + revalidatePath("/dashboard"); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to delete stage."); + } +}; + +export const setCurrentJobStage = async ( + stageId: string, +): Promise => { + try { + const user = await requireUser(); + const stage = await assertStageOwned(stageId, user.id); + + await prisma.$transaction(async (tx: any) => { + await promoteStage(tx, stage.jobId, stageId, user.id); + await syncJobFromCurrentStage(tx, stage.jobId, stageId, user.id); + }); + + revalidatePath("/dashboard"); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to set the current stage."); + } +}; + +// The detail panel edits notes in place, the way its prep checkboxes already +// save in place (Task 5.3). Its own action rather than updateJobStage: that +// one parses the whole Add Stage form, so a notes-only save would have to +// reconstruct a stage type, a date and a set-as-current flag it is not +// changing — and re-running promoteStage as a side effect of typing a note. +export const setStageNotes = async ( + stageId: string, + notes: string, +): Promise => { + try { + const user = await requireUser(); + await assertStageOwned(stageId, user.id); + + const res = await prisma.jobStage.updateMany({ + where: { id: stageId, Job: { userId: user.id } }, + data: { notes: notes.trim() || null }, + }); + if (res.count === 0) throw new Error("Stage not found"); + + return { success: true }; + } catch (error) { + return handleError(error, "Failed to save the note."); + } +}; diff --git a/src/actions/jobStage/queries.ts b/src/actions/jobStage/queries.ts new file mode 100644 index 000000000..19e034d8d --- /dev/null +++ b/src/actions/jobStage/queries.ts @@ -0,0 +1,20 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { STAGE_DETAIL_INCLUDE, sortStages } from "./shared"; + +export const getJobStages = async ( + jobId: string, +): Promise => { + try { + const user = await requireUser(); + const stages = await prisma.jobStage.findMany({ + where: { jobId, Job: { userId: user.id } }, + include: STAGE_DETAIL_INCLUDE, + }); + return sortStages(stages); + } catch (error) { + return handleError(error, "Failed to fetch job stages. "); + } +}; From e0bc248ff6ba15913abb1aaf4e2609fa81a40342 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 09:11:34 -0600 Subject: [PATCH 06/30] feat(stages): make updateJobStatus and the Edit Job status field append a current stage, and stop re-stamping appliedDate Co-Authored-By: Claude Opus 5 --- __tests__/job.actions.spec.ts | 36 +++++++ __tests__/jobStatusStage.spec.ts | 157 +++++++++++++++++++++++++++++++ src/actions/job/mutations.ts | 58 +++++++----- src/actions/job/shared.ts | 35 +++++++ src/actions/job/status.ts | 53 ++++++----- 5 files changed, 290 insertions(+), 49 deletions(-) create mode 100644 __tests__/jobStatusStage.spec.ts diff --git a/__tests__/job.actions.spec.ts b/__tests__/job.actions.spec.ts index 20703bf27..7cb3d40dd 100644 --- a/__tests__/job.actions.spec.ts +++ b/__tests__/job.actions.spec.ts @@ -14,6 +14,7 @@ import { import { getMockJobDetails, getMockJobsList } from "@/lib/mock.utils"; import { JobResponse } from "@/models/job.model"; import { getCurrentUser } from "@/utils/user.utils"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); @@ -52,7 +53,17 @@ vi.mock("@prisma/client", () => { resume: { count: vi.fn() }, coverLetter: { count: vi.fn() }, tag: { count: vi.fn() }, + jobStage: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(), }; + (mPrismaClient.$transaction as any).mockImplementation((fn: any) => + fn(mPrismaClient), + ); return { PrismaClient: vi.fn(function () { return mPrismaClient; @@ -60,6 +71,13 @@ vi.mock("@prisma/client", () => { }; }); +// Only the stage-type resolver is stubbed: the other resolvers in that module +// are used by addJob and the reference-create actions in this same spec. +vi.mock("@/lib/jobs/resolve", async (importOriginal) => ({ + ...((await importOriginal()) as object), + resolveStageTypeForStatusId: vi.fn(), +})); + vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn(), })); @@ -103,6 +121,12 @@ describe("jobActions", () => { for (const model of refModels) { (prisma as any)[model].count.mockResolvedValue(1); } + // Both status write paths now append a stage before updating the job. + (prisma as any).$transaction.mockImplementation((fn: any) => fn(prisma)); + (prisma as any).job.count.mockResolvedValue(1); + (prisma as any).jobStage.create.mockResolvedValue({ id: "stage-id" }); + (prisma as any).jobStage.updateMany.mockResolvedValue({ count: 1 }); + (resolveStageTypeForStatusId as any).mockResolvedValue("stage-type-id"); }); describe("getStatusList", () => { it("should return status list on successful query", async () => { @@ -1212,6 +1236,18 @@ describe("jobActions", () => { expect(result).toStrictEqual({ data: jobData, success: true }); expect(prisma.job.update).toHaveBeenCalledTimes(1); }); + it("appends a status stage when the Edit Job dialog changes the status", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.job.update as any).mockResolvedValue(jobData); + (prisma as any).jobStage.findFirst.mockResolvedValue({ + id: "st-old", + StageType: { statusId: "s-old", Status: { value: "applied" } }, + }); + + await updateJob({ ...jobData, status: "s-new" }); + + expect((prisma as any).jobStage.create).toHaveBeenCalled(); + }); it("should handle unexpected errors", async () => { (getCurrentUser as any).mockResolvedValue(mockUser); diff --git a/__tests__/jobStatusStage.spec.ts b/__tests__/jobStatusStage.spec.ts new file mode 100644 index 000000000..69cb0215d --- /dev/null +++ b/__tests__/jobStatusStage.spec.ts @@ -0,0 +1,157 @@ +import { updateJobStatus } from "@/actions/job.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => { + const jobStage = { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }; + const job = { + update: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), + }; + const client = { + jobStage, + job, + $transaction: vi.fn(async (fn: any) => fn(client)), + }; + return { default: client }; +}); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ + resolveStageTypeForStatusId: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; +const interview = { id: "s-int", label: "Interview", value: "interview" }; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + (resolveStageTypeForStatusId as any).mockResolvedValue("t-int"); + db.job.count.mockResolvedValue(1); + db.job.findFirst.mockResolvedValue({ appliedDate: null }); + db.job.update.mockResolvedValue({ id: "j1" }); + db.jobStage.updateMany.mockResolvedValue({ count: 1 }); +}); + +describe("updateJobStatus", () => { + // The comparison is on PARENT STATUS, not stage type: re-picking Interview + // while the current stage is "2nd Technical Interview" must append nothing, + // or the timeline reads as a step backwards into screening. + it("appends nothing when the current stage already carries that status", async () => { + db.jobStage.findFirst.mockResolvedValue({ + id: "st-tech", + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + const res = await updateJobStatus("j1", interview as any); + + expect(res.success).toBe(true); + expect(db.jobStage.create).not.toHaveBeenCalled(); + expect(db.job.update.mock.calls[0][0].data).toEqual({ + statusId: "s-int", + applied: true, + }); + }); + + it("appends an undated current stage when the status genuinely changes", async () => { + db.jobStage.findFirst.mockResolvedValue({ + id: "st-app", + StageType: { statusId: "s-app", Status: { value: "applied" } }, + }); + db.jobStage.create.mockResolvedValue({ id: "st-new" }); + + const res = await updateJobStatus("j1", interview as any); + + expect(res.success).toBe(true); + expect(db.jobStage.create.mock.calls[0][0].data).toEqual({ + jobId: "j1", + stageTypeId: "t-int", + occurredAt: null, + isCurrent: false, + }); + // D8: the helper scopes its own writes rather than trusting the caller. + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { + jobId: "j1", + isCurrent: true, + NOT: { id: "st-new" }, + Job: { userId: user.id }, + }, + data: { isCurrent: false }, + }); + }); + + // D4: resolving writes through the base client, so it must not happen while + // the transaction holds SQLite's write lock. + it("resolves the stage type before opening the transaction", async () => { + db.jobStage.findFirst.mockResolvedValue(null); + db.jobStage.create.mockResolvedValue({ id: "st-first" }); + + await updateJobStatus("j1", interview as any); + + const resolveOrder = (resolveStageTypeForStatusId as any).mock + .invocationCallOrder[0]; + const txOrder = (db.$transaction as any).mock.invocationCallOrder[0]; + expect(resolveOrder).toBeLessThan(txOrder); + }); + + it("appends a first stage for a stageless job without failing", async () => { + db.jobStage.findFirst.mockResolvedValue(null); + db.jobStage.create.mockResolvedValue({ id: "st-first" }); + + const res = await updateJobStatus("j1", interview as any); + + expect(res.success).toBe(true); + expect(db.jobStage.create).toHaveBeenCalled(); + }); + + it("refuses a job the caller does not own before writing a stage", async () => { + db.job.count.mockResolvedValue(0); + + const res = await updateJobStatus("someone-elses", interview as any); + + expect(res.success).toBe(false); + expect(db.jobStage.create).not.toHaveBeenCalled(); + }); + + it("still writes applied and appliedDate for an Applied pick", async () => { + db.jobStage.findFirst.mockResolvedValue(null); + db.jobStage.create.mockResolvedValue({ id: "st-app" }); + + await updateJobStatus("j1", { + id: "s-app", + label: "Applied", + value: "applied", + } as any); + + const data = db.job.update.mock.calls[0][0].data; + expect(data.applied).toBe(true); + expect(data.appliedDate).toBeInstanceOf(Date); + }); + + // D5: a deliberate change from today's behaviour, which re-stamped the date + // on every Applied pick and moved the job on the dashboard's weekly chart. + it("never moves an appliedDate the job already has", async () => { + const original = new Date("2026-09-03T00:00:00Z"); + db.jobStage.findFirst.mockResolvedValue(null); + db.jobStage.create.mockResolvedValue({ id: "st-app" }); + db.job.findFirst.mockResolvedValue({ appliedDate: original }); + + await updateJobStatus("j1", { + id: "s-app", + label: "Applied", + value: "applied", + } as any); + + expect(db.job.update.mock.calls[0][0].data).not.toHaveProperty("appliedDate"); + }); +}); diff --git a/src/actions/job/mutations.ts b/src/actions/job/mutations.ts index 10ac08eb6..b08b6ff32 100644 --- a/src/actions/job/mutations.ts +++ b/src/actions/job/mutations.ts @@ -6,6 +6,8 @@ import { AddJobFormSchema } from "@/models/addJobForm.schema"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { requireUser } from "../shared"; +import { appendStatusStage } from "./shared"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; type JobRefs = { jobTitleId?: string | null; @@ -184,30 +186,38 @@ export const updateJob = async ( tagIds: tagIds.filter((tagId) => !currentTagIds.has(tagId)), }); - const job = await prisma.job.update({ - where: { - id, - userId: user.id, - }, - data: { - jobTitleId: title, - companyId: company, - locationId: location, - statusId: status, - jobSourceId: source, - salaryRange: salaryRange || null, - createdAt: new Date(), - dueDate: dueDate, - appliedDate: dateApplied, - description: jobDescription, - jobType: type, - workplaceType, - jobUrl, - applied, - resumeId: resume, - coverLetterId: coverLetter, - tags: { set: tagIds.map((id) => ({ id })) }, - }, + // Same D4 rule: resolve before the transaction opens. + const stageTypeId = await resolveStageTypeForStatusId(status, user.id); + + const job = await prisma.$transaction(async (tx: any) => { + // The Edit Job dialog's Status field is the fourth entry point into the + // timeline; without this a job and its stages go permanently out of step. + await appendStatusStage(tx, id, status, stageTypeId, user.id); + return tx.job.update({ + where: { + id, + userId: user.id, + }, + data: { + jobTitleId: title, + companyId: company, + locationId: location, + statusId: status, + jobSourceId: source, + salaryRange: salaryRange || null, + createdAt: new Date(), + dueDate: dueDate, + appliedDate: dateApplied, + description: jobDescription, + jobType: type, + workplaceType, + jobUrl, + applied, + resumeId: resume, + coverLetterId: coverLetter, + tags: { set: tagIds.map((id) => ({ id })) }, + }, + }); }); revalidatePath("/dashboard"); return { success: true, data: job }; diff --git a/src/actions/job/shared.ts b/src/actions/job/shared.ts index 90fc2949e..5ebed15aa 100644 --- a/src/actions/job/shared.ts +++ b/src/actions/job/shared.ts @@ -1,6 +1,8 @@ // Not a "use server" module: job/queries.ts and company/queries.ts both map // job rows through this, and exporting it from either would make it an action. +import { promoteStage } from "@/actions/jobStage/shared"; + // An automation job saved without LLM analysis carries only its keyword // pre-rank in matchScore, which the list must not show as an AI match. The // matchData body is dropped so the list payload stays small. @@ -13,3 +15,36 @@ export function hideUnanalyzedScore< } catch {} return analyzed ? job : { ...job, matchScore: null }; } + +type StatusStageTx = Parameters[0] & { + jobStage: { findFirst: (args: any) => Promise; create: (args: any) => Promise }; +}; + +// The status dropdowns' half of the derived-status invariant. Compares PARENT +// STATUS, not stage type: re-picking Interview while the current stage is +// "2nd Technical Interview" must append nothing (spec decision 16). +// +// stageTypeId is an argument, never resolved here: resolveStageTypeForStatusId +// writes through the base prisma client, and calling it inside the caller's +// transaction contends for SQLite's write lock (D4). +export async function appendStatusStage( + tx: StatusStageTx, + jobId: string, + statusId: string, + stageTypeId: string, + userId: string, +): Promise { + const current = await tx.jobStage.findFirst({ + where: { jobId, isCurrent: true, Job: { userId } }, + select: { + id: true, + StageType: { select: { statusId: true, Status: { select: { value: true } } } }, + }, + }); + if (current?.StageType.statusId === statusId) return; + + const stage = await tx.jobStage.create({ + data: { jobId, stageTypeId, occurredAt: null, isCurrent: false }, + }); + await promoteStage(tx, jobId, stage.id, userId); +} diff --git a/src/actions/job/status.ts b/src/actions/job/status.ts index 41e996a27..85e7716a3 100644 --- a/src/actions/job/status.ts +++ b/src/actions/job/status.ts @@ -4,6 +4,9 @@ import { handleError } from "@/lib/utils"; import { JobStatus } from "@/models/job.model"; import { revalidatePath } from "next/cache"; import { requireUser } from "../shared"; +import { appendStatusStage } from "./shared"; +import { jobFieldsForStage } from "../jobStage/shared"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; export const updateJobStatus = async ( jobId: string, @@ -11,33 +14,33 @@ export const updateJobStatus = async ( ): Promise => { try { const user = await requireUser(); - const dataToUpdate = () => { - switch (status.value) { - case "applied": - return { - statusId: status.id, - applied: true, - appliedDate: new Date(), - }; - case "interview": - return { - statusId: status.id, - applied: true, - }; - default: - return { - statusId: status.id, - }; - } - }; - const job = await prisma.job.update({ - where: { - id: jobId, - userId: user.id, - }, - data: dataToUpdate(), + const owned = await prisma.job.count({ + where: { id: jobId, userId: user.id }, }); + if (owned === 0) throw new Error("Job not found"); + + // Outside the transaction on purpose: this resolve can create a row, and + // it writes through the base client (D4). + const stageTypeId = await resolveStageTypeForStatusId(status.id, user.id); + + const job = await prisma.$transaction(async (tx: any) => { + await appendStatusStage(tx, jobId, status.id, stageTypeId, user.id); + const existing = await tx.job.findFirst({ + where: { id: jobId, userId: user.id }, + select: { appliedDate: true }, + }); + return tx.job.update({ + where: { id: jobId, userId: user.id }, + data: jobFieldsForStage( + status.value, + status.id, + null, + existing?.appliedDate ?? null, + ), + }); + }); + revalidatePath("/dashboard"); return { job, success: true }; } catch (error) { From 211df3cbaacc8dcbdbe9aebcc8f9dd9513941f3a Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 09:14:47 -0600 Subject: [PATCH 07/30] feat(stages): write a first current stage from every job creation path and load stages on the details page Co-Authored-By: Claude Opus 5 --- __tests__/ats-runner.spec.ts | 19 ++++++- __tests__/createJobStage.spec.ts | 55 +++++++++++++++++++ __tests__/persistDiscoveredJobStage.spec.ts | 60 +++++++++++++++++++++ src/actions/job/queries.ts | 27 ++++++++++ src/actions/mock.actions.ts | 12 ++++- src/lib/jobs/createJobRecord.ts | 28 +++++++++- src/lib/scraper/automation-run/persist.ts | 10 +++- src/models/job.model.ts | 4 ++ 8 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 __tests__/createJobStage.spec.ts create mode 100644 __tests__/persistDiscoveredJobStage.spec.ts diff --git a/__tests__/ats-runner.spec.ts b/__tests__/ats-runner.spec.ts index d68e16703..d9786720e 100644 --- a/__tests__/ats-runner.spec.ts +++ b/__tests__/ats-runner.spec.ts @@ -17,7 +17,13 @@ vi.mock("@prisma/client", () => { location: { findUnique: vi.fn(), create: vi.fn() }, company: { findUnique: vi.fn(), create: vi.fn() }, jobSource: { findUnique: vi.fn(), create: vi.fn() }, - jobStatus: { findFirst: vi.fn(), create: vi.fn() }, + jobStatus: { findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn() }, + jobStage: { create: vi.fn() }, + jobStageType: { + findUnique: vi.fn(), + aggregate: vi.fn(), + create: vi.fn(), + }, }; return { PrismaClient: vi.fn(function () { @@ -118,6 +124,17 @@ describe("runAutomation (lever)", () => { (prisma.company.findUnique as any).mockResolvedValue({ id: "co" }); (prisma.jobSource.findUnique as any).mockResolvedValue({ id: "src" }); (prisma.jobStatus.findFirst as any).mockResolvedValue({ id: "st" }); + // Every saved job now gets its first current stage, which resolves the + // status-named stage type through the same Prisma mock. + (prisma.jobStatus.findUnique as any).mockResolvedValue({ + id: "st", + label: "New", + }); + (prisma.jobStageType.findUnique as any).mockResolvedValue({ + id: "stage-type-new", + label: "New", + }); + (prisma.jobStage.create as any).mockResolvedValue({ id: "stage-1" }); (generateText as any).mockResolvedValue({ text: "SCORES: match=90 recommendation=strong match\n\n## Summary\nGreat fit", diff --git a/__tests__/createJobStage.spec.ts b/__tests__/createJobStage.spec.ts new file mode 100644 index 000000000..af4f9cee1 --- /dev/null +++ b/__tests__/createJobStage.spec.ts @@ -0,0 +1,55 @@ +import { createJobRecord, createFirstStage } from "@/lib/jobs/createJobRecord"; +import prisma from "@/lib/db"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => ({ + default: { + job: { create: vi.fn() }, + jobStage: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/jobs/resolve", () => ({ + resolveStageTypeForStatusId: vi.fn(), +})); + +const db = prisma as any; + +beforeEach(() => { + vi.clearAllMocks(); + (resolveStageTypeForStatusId as any).mockResolvedValue("t-draft"); + db.job.create.mockResolvedValue({ id: "j1", createdAt: new Date("2026-09-19T00:00:00Z") }); +}); + +describe("createFirstStage", () => { + it("writes one current stage dated at the job's creation", async () => { + const created = new Date("2026-09-19T00:00:00Z"); + + await createFirstStage(db, "j1", "s-draft", "u1", created); + + expect(resolveStageTypeForStatusId).toHaveBeenCalledWith("s-draft", "u1"); + expect(db.jobStage.create).toHaveBeenCalledWith({ + data: { + jobId: "j1", + stageTypeId: "t-draft", + occurredAt: created, + isCurrent: true, + }, + }); + }); +}); + +describe("createJobRecord", () => { + it("gives every new job exactly one current stage", async () => { + await createJobRecord({ + jobTitleId: "t1", + companyId: "c1", + statusId: "s-draft", + description: "

x

", + jobType: "full-time", + userId: "u1", + } as any); + + expect(db.jobStage.create).toHaveBeenCalledTimes(1); + expect(db.jobStage.create.mock.calls[0][0].data.isCurrent).toBe(true); + }); +}); diff --git a/__tests__/persistDiscoveredJobStage.spec.ts b/__tests__/persistDiscoveredJobStage.spec.ts new file mode 100644 index 000000000..6555914ce --- /dev/null +++ b/__tests__/persistDiscoveredJobStage.spec.ts @@ -0,0 +1,60 @@ +import { persistDiscoveredJob } from "@/lib/scraper/automation-run/persist"; +import { mapScrapedJobToJobRecord } from "@/lib/scraper/mapper"; +import db from "@/lib/db"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => ({ + default: { job: { create: vi.fn() }, jobStage: { create: vi.fn() } }, +})); +vi.mock("@/lib/scraper/mapper", () => ({ mapScrapedJobToJobRecord: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ + resolveStageTypeForStatusId: vi.fn(), +})); + +const mockDb = db as any; +const createdAt = new Date("2026-09-19T00:00:00Z"); + +const automation = { id: "a1", userId: "u1", jobBoard: "greenhouse" } as any; +const job = { + title: "Engineer", + company: "Acme", + location: "Remote", + description: "Build things", + url: "https://boards.greenhouse.io/acme/jobs/1", +} as any; + +beforeEach(() => { + vi.clearAllMocks(); + (resolveStageTypeForStatusId as any).mockResolvedValue("t-new"); + (mapScrapedJobToJobRecord as any).mockResolvedValue({ + statusId: "s-new", + createdAt, + tags: { connect: [] }, + }); + mockDb.job.create.mockResolvedValue({ id: "j1", createdAt }); +}); + +describe("persistDiscoveredJob", () => { + it("gives a discovered job its first current stage", async () => { + const res = await persistDiscoveredJob(automation, job, 50, {}, []); + + expect(res.saved).toBe(true); + expect(resolveStageTypeForStatusId).toHaveBeenCalledWith("s-new", "u1"); + expect(mockDb.jobStage.create.mock.calls[0][0].data).toEqual({ + jobId: "j1", + stageTypeId: "t-new", + occurredAt: createdAt, + isCurrent: true, + }); + }); + + // A concurrent run won the dedup race, so there is no job to stage. + it("writes no stage when the unique index refuses the job", async () => { + mockDb.job.create.mockRejectedValue({ code: "P2002" }); + + const res = await persistDiscoveredJob(automation, job, 50, {}, []); + + expect(res.saved).toBe(false); + expect(mockDb.jobStage.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/job/queries.ts b/src/actions/job/queries.ts index d0ada893d..32d2c718b 100644 --- a/src/actions/job/queries.ts +++ b/src/actions/job/queries.ts @@ -71,6 +71,33 @@ const JOB_DETAILS_INCLUDE = { }, orderBy: { createdAt: "asc" as const }, }, + // Duplicates STAGE_DETAIL_INCLUDE rather than importing it: this is a + // "use server" module and that const lives in another action directory. + stages: { + include: { + StageType: { include: { Status: true } }, + interviewers: { + include: { + Contact: { + select: { + id: true, + name: true, + title: true, + email: true, + phone: true, + linkedinUrl: true, + Company: { select: { id: true, label: true } }, + }, + }, + }, + orderBy: { createdAt: "asc" as const }, + }, + prepQuestions: { + include: { Question: { select: { id: true, question: true, tags: true } } }, + orderBy: { createdAt: "asc" as const }, + }, + }, + }, }; type JobsListFilters = { diff --git a/src/actions/mock.actions.ts b/src/actions/mock.actions.ts index cd74816e3..62a76fa20 100644 --- a/src/actions/mock.actions.ts +++ b/src/actions/mock.actions.ts @@ -2,6 +2,7 @@ import prisma from "@/lib/db"; import { handleError } from "@/lib/utils"; import { requireUser } from "./shared"; +import { createFirstStage } from "@/lib/jobs/createJobRecord"; import { generateMockActivities } from "@/lib/mock.utils"; import { mockActivityTypes, @@ -573,11 +574,18 @@ export const generateMockJobsAction = async (): Promise => { }; }); - const created = await prisma.job.createMany({ data: jobsData }); + // A loop rather than createMany: createMany returns only a count, so it + // cannot feed createFirstStage the new ids. Dev-only, tens of rows. + let count = 0; + for (const data of jobsData) { + const job = await prisma.job.create({ data }); + await createFirstStage(prisma, job.id, data.statusId, user.id, data.createdAt); + count += 1; + } return { success: true, - message: `Generated ${created.count} mock jobs across the last 30 days.`, + message: `Generated ${count} mock jobs across the last 30 days.`, }; } catch (error) { return handleError(error, "Failed to generate mock jobs"); diff --git a/src/lib/jobs/createJobRecord.ts b/src/lib/jobs/createJobRecord.ts index 04283ca14..7d2f29c06 100644 --- a/src/lib/jobs/createJobRecord.ts +++ b/src/lib/jobs/createJobRecord.ts @@ -1,4 +1,25 @@ import prisma from "@/lib/db"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; + +type StageClient = { + jobStage: { create: (args: any) => Promise }; +}; + +// There is no single choke point for job creation: the scraper and the mock +// generator write jobs directly. Each calls this so a job added after the +// backfill is never left without the timeline a backfilled job has. +export async function createFirstStage( + client: StageClient, + jobId: string, + statusId: string, + userId: string, + occurredAt: Date, +): Promise { + const stageTypeId = await resolveStageTypeForStatusId(statusId, userId); + await client.jobStage.create({ + data: { jobId, stageTypeId, occurredAt, isCurrent: true }, + }); +} export async function createJobRecord(fields: { jobTitleId: string; @@ -22,11 +43,14 @@ export async function createJobRecord(fields: { descriptionCompleteness?: string | null; }) { const { tagIds = [], ...rest } = fields; - return prisma.job.create({ + const createdAt = new Date(); + const job = await prisma.job.create({ data: { ...rest, - createdAt: new Date(), + createdAt, ...(tagIds.length > 0 ? { tags: { connect: tagIds.map((id) => ({ id })) } } : {}), }, }); + await createFirstStage(prisma, job.id, fields.statusId, fields.userId, createdAt); + return job; } diff --git a/src/lib/scraper/automation-run/persist.ts b/src/lib/scraper/automation-run/persist.ts index 70a80ee3a..7fa93f6aa 100644 --- a/src/lib/scraper/automation-run/persist.ts +++ b/src/lib/scraper/automation-run/persist.ts @@ -3,6 +3,7 @@ import { APP_CONSTANTS } from "@/lib/constants"; import type { Automation, ScrapedJobData } from "@/models/automation.model"; import type { JobDetails } from "../types"; import { mapScrapedJobToJobRecord } from "../mapper"; +import { createFirstStage } from "@/lib/jobs/createJobRecord"; import { normalizeJobUrl } from "../utils"; import type { SkillTerm } from "./skillTags"; @@ -52,7 +53,14 @@ export async function persistDiscoveredJob( }); try { - await db.job.create({ data: jobRecord }); + const created = await db.job.create({ data: jobRecord }); + await createFirstStage( + db, + created.id, + jobRecord.statusId, + automation.userId, + jobRecord.createdAt, + ); return { saved: true, tagsApplied: jobRecord.tags?.connect.length ?? 0 }; } catch (err: any) { if (err?.code === "P2002") return { saved: false, tagsApplied: 0 }; diff --git a/src/models/job.model.ts b/src/models/job.model.ts index 4f06f28d6..870411226 100644 --- a/src/models/job.model.ts +++ b/src/models/job.model.ts @@ -1,6 +1,9 @@ import type { JobBoard, LeverHost } from "@/models/automation.model"; import { CoverLetter, Resume } from "./profile.model"; import type { JobContactLink } from "./contact.model"; +// Type-only: jobStage.model.ts imports Tag from here and has a runtime export, +// so a value import back would make a real module cycle. +import type { JobStage } from "./jobStage.model"; export interface JobForm { id?: string; @@ -57,6 +60,7 @@ export interface JobResponse { matchData?: string | null; tags?: Tag[]; contactLinks?: JobContactLink[]; + stages?: JobStage[]; createdVia?: string | null; discoveryStatus?: string | null; descriptionCompleteness?: DescriptionCompleteness | null; From 3e53d1e683b02752f3a9dbb8d5d3023cea5a76ea Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 12:49:37 -0600 Subject: [PATCH 08/30] feat(stages): add stage-type list, create, rename and guarded delete actions Co-Authored-By: Claude Opus 5 --- __tests__/jobStageType.actions.spec.ts | 104 ++++++++++++++++++++++ src/actions/jobStageType.actions.ts | 116 +++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 __tests__/jobStageType.actions.spec.ts create mode 100644 src/actions/jobStageType.actions.ts diff --git a/__tests__/jobStageType.actions.spec.ts b/__tests__/jobStageType.actions.spec.ts new file mode 100644 index 000000000..97e956459 --- /dev/null +++ b/__tests__/jobStageType.actions.spec.ts @@ -0,0 +1,104 @@ +import { + getAllJobStageTypes, + getJobStageTypeList, + createJobStageType, + deleteJobStageTypeById, +} from "@/actions/jobStageType.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; +import { resolveJobStageType } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => ({ + default: { + jobStageType: { + findMany: vi.fn(), + count: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + jobStage: { count: vi.fn() }, + jobStatus: { count: vi.fn() }, + }, +})); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ resolveJobStageType: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + db.jobStage.count.mockResolvedValue(0); + db.jobStatus.count.mockResolvedValue(1); +}); + +describe("getAllJobStageTypes", () => { + it("scopes to the caller and orders by sortOrder then label", async () => { + db.jobStageType.findMany.mockResolvedValue([]); + + await getAllJobStageTypes(); + + const args = db.jobStageType.findMany.mock.calls[0][0]; + expect(args.where).toEqual({ createdBy: user.id }); + expect(args.orderBy).toEqual([{ sortOrder: "asc" }, { label: "asc" }]); + expect(args.include).toEqual({ Status: true }); + }); +}); + +describe("getJobStageTypeList", () => { + // getReferenceEntityList groups by an FK on Job, and a stage type reaches + // Job only through JobStage — so this is a dedicated read, like roles. + it("counts stages through the relation, not through a job groupBy", async () => { + db.jobStageType.findMany.mockResolvedValue([]); + db.jobStageType.count.mockResolvedValue(0); + + await getJobStageTypeList(1, 25); + + const args = db.jobStageType.findMany.mock.calls[0][0]; + expect(args.include._count).toEqual({ select: { stages: true } }); + }); +}); + +describe("createJobStageType", () => { + it("refuses a status that does not exist", async () => { + db.jobStatus.count.mockResolvedValue(0); + + const res = await createJobStageType("Panel", "nope"); + + expect(res.success).toBe(false); + expect(resolveJobStageType).not.toHaveBeenCalled(); + }); + + it("delegates to the resolver so canonical-equal names collapse", async () => { + (resolveJobStageType as any).mockResolvedValue({ id: "t1", label: "Panel", created: true }); + + const res = await createJobStageType(" Panel ", "s-int"); + + expect(res.success).toBe(true); + expect(resolveJobStageType).toHaveBeenCalledWith(" Panel ", user.id, "s-int"); + }); +}); + +describe("deleteJobStageTypeById", () => { + it("blocks the delete with a counted message when stages use the type", async () => { + db.jobStage.count.mockResolvedValue(4); + + const res = await deleteJobStageTypeById("t1"); + + expect(res.success).toBe(false); + expect(res.message).toContain("4"); + expect(db.jobStageType.delete).not.toHaveBeenCalled(); + }); + + it("deletes an unused type, scoped to the caller", async () => { + db.jobStageType.delete.mockResolvedValue({ id: "t1" }); + + const res = await deleteJobStageTypeById("t1"); + + expect(res.success).toBe(true); + expect(db.jobStageType.delete).toHaveBeenCalledWith({ + where: { id: "t1", createdBy: user.id }, + }); + }); +}); diff --git a/src/actions/jobStageType.actions.ts b/src/actions/jobStageType.actions.ts new file mode 100644 index 000000000..80e2ceaa3 --- /dev/null +++ b/src/actions/jobStageType.actions.ts @@ -0,0 +1,116 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "./shared"; +import { APP_CONSTANTS } from "@/lib/constants"; +import { resolveJobStageType } from "@/lib/jobs/resolve"; + +export const getAllJobStageTypes = async (): Promise => { + try { + const user = await requireUser(); + return await prisma.jobStageType.findMany({ + where: { createdBy: user.id }, + include: { Status: true }, + orderBy: [{ sortOrder: "asc" }, { label: "asc" }], + }); + } catch (error) { + return handleError(error, "Failed to fetch stage types. "); + } +}; + +// Not getReferenceEntityList: that helper counts jobs grouped by an FK on Job, +// and a stage type reaches Job only through JobStage. +export const getJobStageTypeList = async ( + page: number = 1, + limit: number = APP_CONSTANTS.RECORDS_PER_PAGE, + search?: string, +): Promise => { + try { + const user = await requireUser(); + const whereClause: any = { createdBy: user.id }; + if (search) { + whereClause.label = { contains: search }; + } + + const [data, total] = await Promise.all([ + prisma.jobStageType.findMany({ + where: whereClause, + skip: (page - 1) * limit, + take: limit, + include: { Status: true, _count: { select: { stages: true } } }, + orderBy: [{ sortOrder: "asc" }, { label: "asc" }], + }), + prisma.jobStageType.count({ where: whereClause }), + ]); + + return { data, total }; + } catch (error) { + return handleError(error, "Failed to fetch stage type list. "); + } +}; + +const assertStatusExists = async (statusId: string) => { + const status = await prisma.jobStatus.count({ where: { id: statusId } }); + if (status === 0) throw new Error("Job status not found"); +}; + +export const createJobStageType = async ( + label: string, + statusId: string, +): Promise => { + try { + const user = await requireUser(); + await assertStatusExists(statusId); + const data = await resolveJobStageType(label, user.id, statusId); + return { success: true, data }; + } catch (error) { + return handleError(error, "Failed to create stage type."); + } +}; + +export const updateJobStageType = async ( + id: string, + label: string, + statusId: string, + sortOrder: number, +): Promise => { + try { + const user = await requireUser(); + await assertStatusExists(statusId); + // value is left alone on rename: it is the reverse-lookup key that a + // status resolves through, and rewriting it would orphan the mapping. + await prisma.jobStageType.update({ + where: { id, createdBy: user.id }, + data: { label: label.trim(), statusId, sortOrder }, + }); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to update stage type."); + } +}; + +export const deleteJobStageTypeById = async ( + id: string, +): Promise => { + try { + const user = await requireUser(); + + // JobStage.stageTypeId is required, so the database already refuses via + // the default Restrict; this turns that into a readable message. + const stages = await prisma.jobStage.count({ + where: { stageTypeId: id, Job: { userId: user.id } }, + }); + if (stages > 0) { + throw new Error( + `Stage type cannot be deleted due to ${stages} job stage${stages === 1 ? "" : "s"} using it! `, + ); + } + + const res = await prisma.jobStageType.delete({ + where: { id, createdBy: user.id }, + }); + return { res, success: true }; + } catch (error) { + return handleError(error, "Failed to delete stage type."); + } +}; From 39e49be648d699d59b9ab2cb8e9cfd888a6cadd9 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 12:50:47 -0600 Subject: [PATCH 09/30] feat(stages): append a current stage when MCP updates a job's status, stop re-stamping appliedDate there, and assert stages on the job-details include Co-Authored-By: Claude Opus 5 --- __tests__/job.actions.spec.ts | 27 ++++++++++++ __tests__/updateJobFromNames.spec.ts | 66 +++++++++++++++++++++++++++- src/lib/jobs/updateJobFromNames.ts | 31 ++++++++++--- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/__tests__/job.actions.spec.ts b/__tests__/job.actions.spec.ts index 7cb3d40dd..6f6324db5 100644 --- a/__tests__/job.actions.spec.ts +++ b/__tests__/job.actions.spec.ts @@ -850,6 +850,33 @@ describe("jobActions", () => { }, orderBy: { createdAt: "asc" }, }, + stages: { + include: { + StageType: { include: { Status: true } }, + interviewers: { + include: { + Contact: { + select: { + id: true, + name: true, + title: true, + email: true, + phone: true, + linkedinUrl: true, + Company: { select: { id: true, label: true } }, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }, + prepQuestions: { + include: { + Question: { select: { id: true, question: true, tags: true } }, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }, }, }); }); diff --git a/__tests__/updateJobFromNames.spec.ts b/__tests__/updateJobFromNames.spec.ts index 6bafaeeec..fdce0d83f 100644 --- a/__tests__/updateJobFromNames.spec.ts +++ b/__tests__/updateJobFromNames.spec.ts @@ -8,15 +8,19 @@ import { resolveWorkplaceType, resolveJobStatus, resolveTags, + resolveStageTypeForStatusId, } from "@/lib/jobs/resolve"; import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); vi.mock("@prisma/client", () => { - const mPrismaClient = { + const mPrismaClient: any = { job: { findFirst: vi.fn(), update: vi.fn() }, + jobStage: { findFirst: vi.fn(), create: vi.fn(), updateMany: vi.fn() }, + $transaction: vi.fn(), }; + mPrismaClient.$transaction.mockImplementation((fn: any) => fn(mPrismaClient)); return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; }); @@ -29,6 +33,7 @@ vi.mock("@/lib/jobs/resolve", () => ({ resolveWorkplaceType: vi.fn(), resolveJobStatus: vi.fn(), resolveTags: vi.fn(), + resolveStageTypeForStatusId: vi.fn(), })); const userId = "user-1"; @@ -50,6 +55,10 @@ describe("updateJobFromNames", () => { (resolveJobType as any).mockReturnValue("FT"); (resolveWorkplaceType as any).mockReturnValue("REMOTE"); (resolveTags as any).mockResolvedValue({ resolved: [], dropped: [] }); + (resolveStageTypeForStatusId as any).mockResolvedValue("t-offer"); + (prisma as any).$transaction.mockImplementation((fn: any) => fn(prisma)); + (prisma as any).jobStage.create.mockResolvedValue({ id: "st-new" }); + (prisma as any).jobStage.updateMany.mockResolvedValue({ count: 1 }); }); it("returns updated:false when the job is not the caller's MCP-created job", async () => { @@ -67,7 +76,7 @@ describe("updateJobFromNames", () => { expect(prisma.job.findFirst).toHaveBeenCalledWith({ where: { id: "job-1", userId, createdVia: { not: null } }, - select: { id: true, descriptionCompleteness: true }, + select: { id: true, descriptionCompleteness: true, appliedDate: true }, }); }); @@ -167,4 +176,57 @@ describe("updateJobFromNames", () => { expect(result.message).toContain("No fields to update"); expect(prisma.job.update).not.toHaveBeenCalled(); }); + + // D9: the sixth write path to Job.statusId keeps the timeline in step. + it("appends a current stage when MCP changes the job's status", async () => { + (resolveJobStatus as any).mockResolvedValue("s-offer"); + (prisma as any).jobStage.findFirst.mockResolvedValue({ + id: "st-int", + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + await updateJobFromNames({ jobId: "job-1", status: "offer" }, userId); + + expect((prisma as any).jobStage.create.mock.calls[0][0].data).toMatchObject({ + jobId: "job-1", + stageTypeId: "t-offer", + occurredAt: null, + }); + }); + + // Decision 16's rule, reached through MCP: the comparison is on parent + // status, so re-sending a status the job already holds is a no-op. + it("appends nothing when the current stage already carries that status", async () => { + (resolveJobStatus as any).mockResolvedValue("s-int"); + (prisma as any).jobStage.findFirst.mockResolvedValue({ + id: "st-tech", + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + await updateJobFromNames({ jobId: "job-1", status: "interview" }, userId); + + expect((prisma as any).jobStage.create).not.toHaveBeenCalled(); + }); + + it("leaves the timeline alone when no status field was sent", async () => { + await updateJobFromNames({ jobId: "job-1", salaryRange: "100k" }, userId); + + expect(resolveStageTypeForStatusId).not.toHaveBeenCalled(); + expect((prisma as any).jobStage.create).not.toHaveBeenCalled(); + }); + + // D5, now covering the MCP path as well. + it("never re-stamps an appliedDate the job already has", async () => { + (prisma.job.findFirst as any).mockResolvedValue({ + id: "job-1", + descriptionCompleteness: null, + appliedDate: new Date("2026-09-03T00:00:00Z"), + }); + + await updateJobFromNames({ jobId: "job-1", applied: true }, userId); + + expect((prisma.job.update as any).mock.calls[0][0].data).not.toHaveProperty( + "appliedDate", + ); + }); }); diff --git a/src/lib/jobs/updateJobFromNames.ts b/src/lib/jobs/updateJobFromNames.ts index 7fdb878e0..b9af4bdce 100644 --- a/src/lib/jobs/updateJobFromNames.ts +++ b/src/lib/jobs/updateJobFromNames.ts @@ -13,8 +13,10 @@ import { resolveWorkplaceType, resolveJobStatus, resolveTags, + resolveStageTypeForStatusId, type ResolvedEntity, } from "./resolve"; +import { appendStatusStage } from "@/actions/job/shared"; // Same renderer config as createJobFromNames — html:false escapes raw HTML. const md = new MarkdownIt({ html: false, linkify: false, breaks: true }); @@ -63,7 +65,7 @@ export async function updateJobFromNames( // caller learns "not found" before any entity is created as a side effect. const existing = await prisma.job.findFirst({ where: { id: jobId, userId, createdVia: { not: null } }, - select: { id: true, descriptionCompleteness: true }, + select: { id: true, descriptionCompleteness: true, appliedDate: true }, }); if (!existing) { return { @@ -124,7 +126,13 @@ export async function updateJobFromNames( if (input.jobUrl !== undefined) data.jobUrl = normalizeJobUrl(input.jobUrl); if (input.applied !== undefined) data.applied = input.applied; if (input.appliedDate !== undefined) data.appliedDate = input.appliedDate; - if (input.applied === true && input.appliedDate === undefined) { + // D5: an appliedDate already on the job is never overwritten, the same rule + // updateJobStatus and every stage write path now follow. + if ( + input.applied === true && + input.appliedDate === undefined && + !existing.appliedDate + ) { data.appliedDate = new Date(); } if (resolvedTagsResult) { @@ -151,10 +159,23 @@ export async function updateJobFromNames( }; } + // D4: this resolve can create a row and writes through the base client, so + // it must not run while the transaction below holds SQLite's write lock. + const stageTypeId = statusId + ? await resolveStageTypeForStatusId(statusId, userId) + : null; + try { - await prisma.job.update({ - where: { id: jobId, userId, createdVia: { not: null } }, - data, + await prisma.$transaction(async (tx: any) => { + // D9: the sixth write path to Job.statusId. Without this an agent can + // move a job's status while its timeline stays where it was. + if (statusId && stageTypeId) { + await appendStatusStage(tx, jobId, statusId, stageTypeId, userId); + } + await tx.job.update({ + where: { id: jobId, userId, createdVia: { not: null } }, + data, + }); }); } catch (error: any) { if (error?.code === "P2025") { From 7ebbefa5855604adcc4b1f3c9184efe83206c783 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 13:14:18 -0600 Subject: [PATCH 10/30] feat(stages): link and unlink stage interviewers, upserting the job's interviewer contact link Co-Authored-By: Claude Opus 5 --- __tests__/jobStageInterviewers.spec.ts | 145 +++++++++++++++++++++++++ src/actions/jobStage.actions.ts | 4 + src/actions/jobStage/interviewers.ts | 89 +++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 __tests__/jobStageInterviewers.spec.ts create mode 100644 src/actions/jobStage/interviewers.ts diff --git a/__tests__/jobStageInterviewers.spec.ts b/__tests__/jobStageInterviewers.spec.ts new file mode 100644 index 000000000..ea86ea0ae --- /dev/null +++ b/__tests__/jobStageInterviewers.spec.ts @@ -0,0 +1,145 @@ +import { + linkStageInterviewer, + unlinkStageInterviewer, +} from "@/actions/jobStage.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; +import { resolveContactRole } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => ({ + default: { + jobStage: { findFirst: vi.fn() }, + contact: { count: vi.fn() }, + jobStageInterviewer: { + create: vi.fn(), + findFirst: vi.fn(), + deleteMany: vi.fn(), + }, + jobContact: { findFirst: vi.fn(), create: vi.fn() }, + }, +})); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ resolveContactRole: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; + +const interviewStage = { + id: "st1", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: { + id: "t-int", + label: "Final / Onsite Interview", + statusId: "s-int", + Status: { value: "interview" }, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + (resolveContactRole as any).mockResolvedValue({ id: "r-int", label: "Interviewer" }); + db.jobStage.findFirst.mockResolvedValue(interviewStage); + db.contact.count.mockResolvedValue(1); + db.jobContact.findFirst.mockResolvedValue(null); + db.jobStageInterviewer.create.mockResolvedValue({ id: "link1" }); +}); + +describe("linkStageInterviewer", () => { + it("refuses a stage the caller does not own", async () => { + db.jobStage.findFirst.mockResolvedValue(null); + + const res = await linkStageInterviewer("someone-elses-stage", "c1"); + + expect(res.success).toBe(false); + expect(db.jobStageInterviewer.create).not.toHaveBeenCalled(); + }); + + // Owning the stage is not permission to attach someone else's contact. + it("refuses a contact the caller does not own", async () => { + db.contact.count.mockResolvedValue(0); + + const res = await linkStageInterviewer("st1", "someone-elses-contact"); + + expect(res.success).toBe(false); + expect(db.jobStageInterviewer.create).not.toHaveBeenCalled(); + }); + + it("refuses a stage that is not an interview stage", async () => { + db.jobStage.findFirst.mockResolvedValue({ + ...interviewStage, + StageType: { + id: "t-app", + label: "Applied", + statusId: "s-app", + Status: { value: "applied" }, + }, + }); + + const res = await linkStageInterviewer("st1", "c1"); + + expect(res.success).toBe(false); + expect(res.message).toContain("not an interview stage"); + expect(db.jobStageInterviewer.create).not.toHaveBeenCalled(); + }); + + it("links and upserts a JobContact with the interviewer role", async () => { + const res = await linkStageInterviewer("st1", "c1"); + + expect(res.success).toBe(true); + expect(db.jobStageInterviewer.create.mock.calls[0][0].data).toEqual({ + stageId: "st1", + contactId: "c1", + }); + // Resolve-or-create: the seeded role is renameable and deletable. + expect(resolveContactRole).toHaveBeenCalledWith("Interviewer", user.id); + expect(db.jobContact.create.mock.calls[0][0].data).toEqual({ + jobId: "j1", + contactId: "c1", + roleId: "r-int", + }); + }); + + it("does not duplicate an existing JobContact for that role", async () => { + db.jobContact.findFirst.mockResolvedValue({ id: "jc1" }); + + await linkStageInterviewer("st1", "c1"); + + expect(db.jobContact.create).not.toHaveBeenCalled(); + }); + + it("reports a friendly message when the contact is already on the stage", async () => { + db.jobStageInterviewer.create.mockRejectedValue({ code: "P2002" }); + + const res = await linkStageInterviewer("st1", "c1"); + + expect(res.success).toBe(false); + expect(res.message).toContain("already"); + }); +}); + +describe("unlinkStageInterviewer", () => { + it("deletes through both ownership chains and leaves the JobContact alone", async () => { + db.jobStageInterviewer.deleteMany.mockResolvedValue({ count: 1 }); + + const res = await unlinkStageInterviewer("link1"); + + expect(res.success).toBe(true); + expect(db.jobStageInterviewer.deleteMany.mock.calls[0][0].where).toEqual({ + id: "link1", + Stage: { Job: { userId: user.id } }, + Contact: { createdBy: user.id }, + }); + expect(db.jobContact.create).not.toHaveBeenCalled(); + }); + + it("fails when nothing matched", async () => { + db.jobStageInterviewer.deleteMany.mockResolvedValue({ count: 0 }); + + const res = await unlinkStageInterviewer("nope"); + + expect(res.success).toBe(false); + }); +}); diff --git a/src/actions/jobStage.actions.ts b/src/actions/jobStage.actions.ts index cf0170e07..74d771ce5 100644 --- a/src/actions/jobStage.actions.ts +++ b/src/actions/jobStage.actions.ts @@ -8,3 +8,7 @@ export { setCurrentJobStage, setStageNotes, } from "./jobStage/mutations"; +export { + linkStageInterviewer, + unlinkStageInterviewer, +} from "./jobStage/interviewers"; diff --git a/src/actions/jobStage/interviewers.ts b/src/actions/jobStage/interviewers.ts new file mode 100644 index 000000000..51d1e0907 --- /dev/null +++ b/src/actions/jobStage/interviewers.ts @@ -0,0 +1,89 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { resolveContactRole } from "@/lib/jobs/resolve"; +import { assertInterviewStage, assertStageOwned } from "./shared"; + +const INTERVIEWER_ROLE_LABEL = "Interviewer"; + +const INTERVIEWER_INCLUDE = { + Contact: { + select: { + id: true, + name: true, + title: true, + email: true, + phone: true, + linkedinUrl: true, + Company: { select: { id: true, label: true } }, + }, + }, +}; + +export const linkStageInterviewer = async ( + stageId: string, + contactId: string, +): Promise => { + try { + const user = await requireUser(); + + // Two sides, checked independently: JobStageInterviewer has no owner + // column, so owning the stage is not permission to attach this contact. + const [stage, contact] = await Promise.all([ + assertStageOwned(stageId, user.id), + prisma.contact.count({ where: { id: contactId, createdBy: user.id } }), + ]); + if (contact === 0) throw new Error("Contact not found"); + assertInterviewStage(stage); + + const data = await prisma.jobStageInterviewer.create({ + data: { stageId, contactId }, + include: INTERVIEWER_INCLUDE, + }); + + // The job's Contacts tab stays the single roster of everyone involved. + // Resolve-or-create: the seeded role is renameable and deletable. + const role = await resolveContactRole(INTERVIEWER_ROLE_LABEL, user.id); + const existing = await prisma.jobContact.findFirst({ + where: { jobId: stage.jobId, contactId, roleId: role.id }, + select: { id: true }, + }); + if (!existing) { + await prisma.jobContact.create({ + data: { jobId: stage.jobId, contactId, roleId: role.id }, + }); + } + + return { success: true, data }; + } catch (error: any) { + if (error?.code === "P2002") { + return { + success: false, + message: "That contact is already an interviewer on this stage.", + }; + } + return handleError(error, "Failed to link interviewer."); + } +}; + +export const unlinkStageInterviewer = async ( + linkId: string, +): Promise => { + try { + const user = await requireUser(); + // The JobContact is deliberately left in place: the person may hold other + // roles on this job, and unlinking a stage is not unlinking the job. + const res = await prisma.jobStageInterviewer.deleteMany({ + where: { + id: linkId, + Stage: { Job: { userId: user.id } }, + Contact: { createdBy: user.id }, + }, + }); + if (res.count === 0) throw new Error("Interviewer link not found"); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to unlink interviewer."); + } +}; From 83d2f9fb1c3210f85039e8b75ab3ea283c6b9167 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 13:16:02 -0600 Subject: [PATCH 11/30] feat(stages): add prep-list link, remove and asked actions, and guard question deletion Co-Authored-By: Claude Opus 5 --- __tests__/jobStagePrep.spec.ts | 181 +++++++++++++++++++++++++++++ __tests__/question.actions.spec.ts | 26 +++++ src/actions/jobStage.actions.ts | 5 + src/actions/jobStage/prep.ts | 99 ++++++++++++++++ src/actions/question.actions.ts | 11 ++ 5 files changed, 322 insertions(+) create mode 100644 __tests__/jobStagePrep.spec.ts create mode 100644 src/actions/jobStage/prep.ts diff --git a/__tests__/jobStagePrep.spec.ts b/__tests__/jobStagePrep.spec.ts new file mode 100644 index 000000000..b5e552760 --- /dev/null +++ b/__tests__/jobStagePrep.spec.ts @@ -0,0 +1,181 @@ +import { + addStagePrepQuestions, + removeStagePrepQuestion, + setPrepQuestionAsked, +} from "@/actions/jobStage.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; + +vi.mock("@/lib/db", () => ({ + default: { + jobStage: { findFirst: vi.fn() }, + question: { count: vi.fn() }, + jobStagePrepQuestion: { + createMany: vi.fn(), + findMany: vi.fn(), + deleteMany: vi.fn(), + updateMany: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; + +const interviewStage = { + id: "st1", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: { + id: "t-int", + label: "Final / Onsite Interview", + statusId: "s-int", + Status: { value: "interview" }, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + db.jobStage.findFirst.mockResolvedValue(interviewStage); + db.question.count.mockResolvedValue(2); + db.jobStagePrepQuestion.createMany.mockResolvedValue({ count: 2 }); + db.jobStagePrepQuestion.findMany.mockResolvedValue([]); +}); + +describe("addStagePrepQuestions", () => { + // The question count must match the ids passed, or this fails on ownership + // and never reaches the gate it claims to test. + it("refuses a stage that is not an interview stage", async () => { + db.jobStage.findFirst.mockResolvedValue({ + ...interviewStage, + StageType: { id: "t-app", label: "Applied", statusId: "s-app", Status: { value: "applied" } }, + }); + db.question.count.mockResolvedValue(1); + + const res = await addStagePrepQuestions("st1", ["q1"]); + + expect(res.success).toBe(false); + expect(res.message).toContain("not an interview stage"); + expect(res.message).toContain("Applied"); + expect(db.jobStagePrepQuestion.createMany).not.toHaveBeenCalled(); + }); + + it("refuses when any question belongs to someone else", async () => { + db.question.count.mockResolvedValue(1); + + const res = await addStagePrepQuestions("st1", ["q1", "someone-elses"]); + + expect(res.success).toBe(false); + expect(db.jobStagePrepQuestion.createMany).not.toHaveBeenCalled(); + }); + + it("links every question unasked", async () => { + const res = await addStagePrepQuestions("st1", ["q1", "q2"]); + + expect(res.success).toBe(true); + expect(db.jobStagePrepQuestion.createMany).toHaveBeenCalledWith({ + data: [ + { stageId: "st1", questionId: "q1", asked: false }, + { stageId: "st1", questionId: "q2", asked: false }, + ], + }); + }); + + // SQLite has no skipDuplicates, so the subtraction is ours to get right. + it("skips questions already on the stage's prep list", async () => { + db.jobStagePrepQuestion.findMany.mockResolvedValueOnce([ + { questionId: "q1" }, + ]); + + await addStagePrepQuestions("st1", ["q1", "q2"]); + + expect(db.jobStagePrepQuestion.createMany).toHaveBeenCalledWith({ + data: [{ stageId: "st1", questionId: "q2", asked: false }], + }); + }); + + it("writes nothing when every question is already linked", async () => { + db.jobStagePrepQuestion.findMany.mockResolvedValueOnce([ + { questionId: "q1" }, + { questionId: "q2" }, + ]); + + const res = await addStagePrepQuestions("st1", ["q1", "q2"]); + + expect(res.success).toBe(true); + expect(db.jobStagePrepQuestion.createMany).not.toHaveBeenCalled(); + }); + + it("rejects an empty selection", async () => { + const res = await addStagePrepQuestions("st1", []); + + expect(res.success).toBe(false); + expect(db.jobStagePrepQuestion.createMany).not.toHaveBeenCalled(); + }); +}); + +describe("setPrepQuestionAsked", () => { + it("records askedAt when marking asked", async () => { + db.jobStagePrepQuestion.updateMany.mockResolvedValue({ count: 1 }); + db.jobStagePrepQuestion.findFirst.mockResolvedValue({ + id: "p1", + asked: true, + askedAt: new Date(), + }); + + const res = await setPrepQuestionAsked("p1", true); + + expect(res.success).toBe(true); + const call = db.jobStagePrepQuestion.updateMany.mock.calls[0][0]; + expect(call.where).toEqual({ + id: "p1", + Stage: { Job: { userId: user.id } }, + Question: { createdBy: user.id }, + }); + expect(call.data.asked).toBe(true); + expect(call.data.askedAt).toBeInstanceOf(Date); + }); + + it("clears askedAt when unmarking", async () => { + db.jobStagePrepQuestion.updateMany.mockResolvedValue({ count: 1 }); + db.jobStagePrepQuestion.findFirst.mockResolvedValue({ + id: "p1", + asked: false, + askedAt: null, + }); + + await setPrepQuestionAsked("p1", false); + + expect(db.jobStagePrepQuestion.updateMany.mock.calls[0][0].data).toEqual({ + asked: false, + askedAt: null, + }); + }); + + it("fails when the row belongs to someone else", async () => { + db.jobStagePrepQuestion.updateMany.mockResolvedValue({ count: 0 }); + + const res = await setPrepQuestionAsked("someone-elses", true); + + expect(res.success).toBe(false); + }); +}); + +describe("removeStagePrepQuestion", () => { + it("deletes through both ownership chains", async () => { + db.jobStagePrepQuestion.deleteMany.mockResolvedValue({ count: 1 }); + + const res = await removeStagePrepQuestion("p1"); + + expect(res.success).toBe(true); + expect(db.jobStagePrepQuestion.deleteMany.mock.calls[0][0].where).toEqual({ + id: "p1", + Stage: { Job: { userId: user.id } }, + Question: { createdBy: user.id }, + }); + }); +}); diff --git a/__tests__/question.actions.spec.ts b/__tests__/question.actions.spec.ts index 4ad1300cb..0f7a95443 100644 --- a/__tests__/question.actions.spec.ts +++ b/__tests__/question.actions.spec.ts @@ -24,6 +24,9 @@ vi.mock("@prisma/client", () => { tag: { findMany: vi.fn(), }, + jobStagePrepQuestion: { + count: vi.fn(), + }, }; return { PrismaClient: vi.fn(function() { return mPrismaClient; }) }; }); @@ -47,6 +50,7 @@ describe("Question Actions", () => { beforeEach(() => { vi.clearAllMocks(); + (prisma.jobStagePrepQuestion.count as any).mockResolvedValue(0); }); // getQuestionsList @@ -451,6 +455,28 @@ describe("Question Actions", () => { expect(result).toEqual({ success: false, message: "DB error" }); }); + + it("blocks deleting a question that sits on a prep list, naming the count", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.jobStagePrepQuestion.count as any).mockResolvedValue(3); + + const result = await deleteQuestion("q-1"); + + expect(result.success).toBe(false); + expect(result.message).toContain("3"); + expect(prisma.question.delete).not.toHaveBeenCalled(); + }); + + it("deletes a question that is on no prep list", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.jobStagePrepQuestion.count as any).mockResolvedValue(0); + (prisma.question.delete as any).mockResolvedValue(mockQuestion); + + const result = await deleteQuestion("q-1"); + + expect(result.success).toBe(true); + expect(prisma.question.delete).toHaveBeenCalled(); + }); }); // getTagsWithQuestionCounts diff --git a/src/actions/jobStage.actions.ts b/src/actions/jobStage.actions.ts index 74d771ce5..9ea12009c 100644 --- a/src/actions/jobStage.actions.ts +++ b/src/actions/jobStage.actions.ts @@ -12,3 +12,8 @@ export { linkStageInterviewer, unlinkStageInterviewer, } from "./jobStage/interviewers"; +export { + addStagePrepQuestions, + removeStagePrepQuestion, + setPrepQuestionAsked, +} from "./jobStage/prep"; diff --git a/src/actions/jobStage/prep.ts b/src/actions/jobStage/prep.ts new file mode 100644 index 000000000..626bf6ed4 --- /dev/null +++ b/src/actions/jobStage/prep.ts @@ -0,0 +1,99 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { assertInterviewStage, assertStageOwned } from "./shared"; + +const PREP_INCLUDE = { + Question: { select: { id: true, question: true, tags: true } }, +}; + +export const addStagePrepQuestions = async ( + stageId: string, + questionIds: string[], +): Promise => { + try { + const user = await requireUser(); + const ids = [...new Set(questionIds)].filter(Boolean); + if (ids.length === 0) throw new Error("Pick at least one question"); + + const [stage, owned] = await Promise.all([ + assertStageOwned(stageId, user.id), + prisma.question.count({ + where: { id: { in: ids }, createdBy: user.id }, + }), + ]); + // Kind before ownership: the stage is already known to be the caller's, + // and "Applied is not an interview stage" is the message that helps. + assertInterviewStage(stage); + if (owned !== ids.length) throw new Error("Question not found"); + + // SQLite has no skipDuplicates, so the already-linked ids are subtracted + // here rather than left to the unique index. + const linked = await prisma.jobStagePrepQuestion.findMany({ + where: { stageId, questionId: { in: ids } }, + select: { questionId: true }, + }); + const alreadyLinked = new Set(linked.map((row) => row.questionId)); + const fresh = ids.filter((id) => !alreadyLinked.has(id)); + if (fresh.length > 0) { + await prisma.jobStagePrepQuestion.createMany({ + data: fresh.map((questionId) => ({ stageId, questionId, asked: false })), + }); + } + + const data = await prisma.jobStagePrepQuestion.findMany({ + where: { stageId }, + include: PREP_INCLUDE, + orderBy: { createdAt: "asc" }, + }); + return { success: true, data }; + } catch (error) { + return handleError(error, "Failed to add questions to the prep list."); + } +}; + +export const removeStagePrepQuestion = async ( + linkId: string, +): Promise => { + try { + const user = await requireUser(); + const res = await prisma.jobStagePrepQuestion.deleteMany({ + where: { + id: linkId, + Stage: { Job: { userId: user.id } }, + Question: { createdBy: user.id }, + }, + }); + if (res.count === 0) throw new Error("Prep question not found"); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to remove the prep question."); + } +}; + +export const setPrepQuestionAsked = async ( + linkId: string, + asked: boolean, +): Promise => { + try { + const user = await requireUser(); + const res = await prisma.jobStagePrepQuestion.updateMany({ + where: { + id: linkId, + Stage: { Job: { userId: user.id } }, + Question: { createdBy: user.id }, + }, + data: { asked, askedAt: asked ? new Date() : null }, + }); + if (res.count === 0) throw new Error("Prep question not found"); + + const data = await prisma.jobStagePrepQuestion.findFirst({ + where: { id: linkId }, + select: { id: true, asked: true, askedAt: true }, + }); + return { success: true, data }; + } catch (error) { + return handleError(error, "Failed to update the prep question."); + } +}; diff --git a/src/actions/question.actions.ts b/src/actions/question.actions.ts index 24b5612b0..5d35f515e 100644 --- a/src/actions/question.actions.ts +++ b/src/actions/question.actions.ts @@ -132,6 +132,17 @@ export const deleteQuestion = async ( try { const user = await requireUser(); + // Silently erasing the record that a question was asked in a real + // interview is worse than a delete that fails with a reason. + const prepRows = await prisma.jobStagePrepQuestion.count({ + where: { questionId, Question: { createdBy: user.id } }, + }); + if (prepRows > 0) { + throw new Error( + `Question cannot be deleted: it is on the prep list of ${prepRows} interview stage${prepRows === 1 ? "" : "s"}. Remove it from those stages first.`, + ); + } + await prisma.question.delete({ where: { id: questionId, createdBy: user.id }, }); From db49b0983410d3e74c90ae963b13b15fc7d52cd0 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 13:19:07 -0600 Subject: [PATCH 12/30] feat(timeline): add stage display helpers and the shared job-stages hook Co-Authored-By: Claude Opus 5 --- __tests__/stageDisplay.spec.ts | 113 ++++++++++++++++++ .../job-details/timeline/stageDisplay.ts | 65 ++++++++++ .../job-details/timeline/useJobStages.ts | 48 ++++++++ 3 files changed, 226 insertions(+) create mode 100644 __tests__/stageDisplay.spec.ts create mode 100644 src/components/myjobs/job-details/timeline/stageDisplay.ts create mode 100644 src/components/myjobs/job-details/timeline/useJobStages.ts diff --git a/__tests__/stageDisplay.spec.ts b/__tests__/stageDisplay.spec.ts new file mode 100644 index 000000000..e79c106a2 --- /dev/null +++ b/__tests__/stageDisplay.spec.ts @@ -0,0 +1,113 @@ +import { + formatStageDate, + formatStageDateTime, + stageInitials, + isInterviewStage, + terminalStagesFor, + askedTally, +} from "@/components/myjobs/job-details/timeline/stageDisplay"; + +const stage = (over: any = {}) => ({ + id: "s1", + occurredAt: null, + durationMins: null, + prepQuestions: [], + StageType: { id: "t1", value: "offer", sortOrder: 7, Status: { value: "offer" } }, + ...over, +}); + +describe("formatStageDate", () => { + it("renders an em dash for an undated stage rather than a blank", () => { + expect(formatStageDate(null)).toBe("—"); + }); + + it("renders a short month and day", () => { + expect(formatStageDate(new Date(2026, 8, 24, 10, 0))).toBe("Sep 24"); + }); +}); + +describe("formatStageDateTime", () => { + it("says so plainly when there is no date", () => { + expect(formatStageDateTime(null, null)).toBe("No date set"); + }); + + it("includes the duration only when there is one", () => { + const past = new Date(2020, 8, 24, 10, 0); + expect(formatStageDateTime(past, 90)).toBe("Sep 24, 2020 · 10:00 AM · 90 min"); + expect(formatStageDateTime(past, null)).toBe("Sep 24, 2020 · 10:00 AM"); + }); + + // The artboard's wording, kept only where it is true. A past stage that + // reads "Scheduled for" a date already gone is simply wrong, and the + // prefix doubles as the at-a-glance upcoming/done signal. + it('prefixes "Scheduled for" only when the stage is still ahead', () => { + const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + expect(formatStageDateTime(future, 90)).toMatch(/^Scheduled for /); + + const past = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + expect(formatStageDateTime(past, 90)).not.toMatch(/^Scheduled for /); + }); +}); + +describe("stageInitials", () => { + it("takes the first letter of the first two words", () => { + expect(stageInitials("Priya Nair")).toBe("PN"); + expect(stageInitials("Cher")).toBe("C"); + expect(stageInitials(" ")).toBe("?"); + }); +}); + +describe("isInterviewStage", () => { + it("discriminates on the parent status, never on the stage's timing", () => { + expect(isInterviewStage(stage({ StageType: { Status: { value: "interview" } } }) as any)).toBe(true); + expect(isInterviewStage(stage() as any)).toBe(false); + }); +}); + +describe("terminalStagesFor", () => { + const types = [ + { id: "t-off", value: "offer", sortOrder: 7, statusId: "s-off", label: "Offer", Status: { value: "offer" } }, + { id: "t-rej", value: "rejected", sortOrder: 10, statusId: "s-rej", label: "Rejected", Status: { value: "rejected" } }, + { id: "t-int", value: "interview", sortOrder: 3, statusId: "s-int", label: "Interview", Status: { value: "interview" } }, + ] as any; + + // D6: Offer alone. Rejected and Withdrawn are alternative endings, not + // next steps, and the mockup shows a single greyed tail step. + it("returns only the unreached Offer step", () => { + const result = terminalStagesFor([], types); + expect(result.map((t: any) => t.value)).toEqual(["offer"]); + }); + + it("drops the terminal type the job has already reached", () => { + const result = terminalStagesFor( + [stage({ stageTypeId: "t-off" })] as any, + types, + ); + expect(result).toEqual([]); + }); + + // A custom "Verbal Offer" type under the same parent status must not put a + // second greyed Offer step on the rail. + it("returns at most one type per parent status", () => { + const withCustom = [ + ...types, + { id: "t-verbal", value: "verbal offer", sortOrder: 14, statusId: "s-off", label: "Verbal Offer", Status: { value: "offer" } }, + ] as any; + + const result = terminalStagesFor([], withCustom); + expect(result).toHaveLength(1); + expect(result[0].value).toBe("offer"); + }); +}); + +describe("askedTally", () => { + it("counts asked out of total", () => { + expect( + askedTally( + stage({ + prepQuestions: [{ asked: true }, { asked: true }, { asked: false }, { asked: false }], + }) as any, + ), + ).toEqual({ asked: 2, total: 4 }); + }); +}); diff --git a/src/components/myjobs/job-details/timeline/stageDisplay.ts b/src/components/myjobs/job-details/timeline/stageDisplay.ts new file mode 100644 index 000000000..7aee4f21a --- /dev/null +++ b/src/components/myjobs/job-details/timeline/stageDisplay.ts @@ -0,0 +1,65 @@ +import { format } from "date-fns"; +import { TERMINAL_STAGE_STATUSES } from "@/lib/constants"; +import type { JobStage, JobStageTypeRef } from "@/models/jobStage.model"; + +// An undated stage renders an em dash, never a fabricated date or a blank. +export function formatStageDate(occurredAt: Date | null): string { + return occurredAt ? format(occurredAt, "MMM d") : "—"; +} + +// "Scheduled for" only while the stage is still ahead (the artboard's wording, +// kept where it is true): a past stage that reads "Scheduled for" a date +// already gone is wrong, and the prefix doubles as the upcoming/done signal. +export function formatStageDateTime( + occurredAt: Date | null, + durationMins: number | null, +): string { + if (!occurredAt) return "No date set"; + const base = format(occurredAt, "MMM d, yyyy · h:mm a"); + const withDuration = durationMins ? `${base} · ${durationMins} min` : base; + return occurredAt.getTime() > Date.now() + ? `Scheduled for ${withDuration}` + : withDuration; +} + +export function stageInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + return parts + .slice(0, 2) + .map((p) => p[0]!.toUpperCase()) + .join(""); +} + +// The gate is stage KIND, never stage timing: an interview a week away is +// still an interview stage. +export function isInterviewStage(stage: JobStage): boolean { + return stage.StageType?.Status?.value === "interview"; +} + +// One greyed step per unreached terminal STATUS, not per type (D6): a user +// whose Library holds a custom "Verbal Offer" under the offer status would +// otherwise get two greyed Offer steps side by side. +export function terminalStagesFor( + stages: JobStage[], + stageTypes: JobStageTypeRef[], +): JobStageTypeRef[] { + const reachedStatuses = new Set( + stages.map((s) => s.StageType?.Status?.value).filter(Boolean), + ); + const terminal = new Set(TERMINAL_STAGE_STATUSES); + + const byStatus = new Map(); + for (const type of [...stageTypes].sort((a, b) => a.sortOrder - b.sortOrder)) { + const status = type.Status?.value ?? ""; + if (!terminal.has(status)) continue; + if (reachedStatuses.has(status)) continue; + if (!byStatus.has(status)) byStatus.set(status, type); + } + return [...byStatus.values()]; +} + +export function askedTally(stage: JobStage): { asked: number; total: number } { + const rows = stage.prepQuestions ?? []; + return { asked: rows.filter((r) => r.asked).length, total: rows.length }; +} diff --git a/src/components/myjobs/job-details/timeline/useJobStages.ts b/src/components/myjobs/job-details/timeline/useJobStages.ts new file mode 100644 index 000000000..d6d5cdde7 --- /dev/null +++ b/src/components/myjobs/job-details/timeline/useJobStages.ts @@ -0,0 +1,48 @@ +"use client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getJobStages } from "@/actions/jobStage.actions"; +import { sortStages } from "@/lib/jobs/sortStages"; +import type { JobStage } from "@/models/jobStage.model"; + +// Lives in JobDetails' scope, not the tab's: the header's Update Status menu +// is visible on every tab and targets the same selected stage. +export function useJobStages(jobId: string, initial: JobStage[]) { + // The server prop comes from JOB_DETAILS_INCLUDE, which carries no orderBy + // and could not usefully have one (D2) — without this every job opens with + // a scrambled stepper until the first write triggers a refetch. + const [stages, setStages] = useState(() => sortStages(initial)); + const [selectedStageId, setSelectedStageId] = useState(null); + + const currentStage = useMemo( + () => stages.find((s) => s.isCurrent) ?? null, + [stages], + ); + + // Selection defaults to the current stage, and re-homes when the selected + // stage is deleted rather than leaving the panel pointed at nothing. + useEffect(() => { + if (selectedStageId && stages.some((s) => s.id === selectedStageId)) return; + setSelectedStageId(currentStage?.id ?? stages[0]?.id ?? null); + }, [stages, selectedStageId, currentStage]); + + const selectedStage = useMemo( + () => stages.find((s) => s.id === selectedStageId) ?? null, + [stages, selectedStageId], + ); + + // getJobStages already sorts, but sorting again is free and keeps the one + // rule ("this list is always sorted") true of every path into setStages. + const reload = useCallback(async () => { + const fresh = await getJobStages(jobId); + if (Array.isArray(fresh)) setStages(sortStages(fresh)); + }, [jobId]); + + return { + stages, + currentStage, + selectedStage, + selectedStageId, + selectStage: setSelectedStageId, + reload, + }; +} From e9683da53e51cf59b3be9158d5ccb098700e1fe2 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 13:19:59 -0600 Subject: [PATCH 13/30] feat(timeline): add the horizontal stage stepper and the full stage history list Co-Authored-By: Claude Opus 5 --- __tests__/StageStepper.spec.tsx | 118 ++++++++++++++++++ .../job-details/timeline/StageHistoryList.tsx | 103 +++++++++++++++ .../job-details/timeline/StageStepper.tsx | 109 ++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 __tests__/StageStepper.spec.tsx create mode 100644 src/components/myjobs/job-details/timeline/StageHistoryList.tsx create mode 100644 src/components/myjobs/job-details/timeline/StageStepper.tsx diff --git a/__tests__/StageStepper.spec.tsx b/__tests__/StageStepper.spec.tsx new file mode 100644 index 000000000..4660ded38 --- /dev/null +++ b/__tests__/StageStepper.spec.tsx @@ -0,0 +1,118 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StageStepper } from "@/components/myjobs/job-details/timeline/StageStepper"; + +const stage = (id: string, label: string, over: any = {}) => ({ + id, + jobId: "j1", + stageTypeId: `t-${id}`, + occurredAt: null, + isCurrent: false, + outcome: null, + notes: null, + durationMins: null, + format: null, + location: null, + createdAt: new Date(2026, 8, 1), + updatedAt: new Date(2026, 8, 1), + StageType: { id: `t-${id}`, label, value: label.toLowerCase(), statusId: "s", sortOrder: 0, Status: { id: "s", label, value: "interview" } }, + interviewers: [], + prepQuestions: [], + ...over, +}); + +const stages = [ + stage("a", "New", { occurredAt: new Date(2026, 8, 1) }), + stage("b", "Applied", { occurredAt: new Date(2026, 8, 3) }), + stage("c", "Final / Onsite Interview", { occurredAt: new Date(2026, 8, 24), isCurrent: true }), +] as any; + +const terminalTypes = [ + { id: "t-off", label: "Offer", value: "offer", statusId: "s-off", sortOrder: 7, Status: { value: "offer" } }, +] as any; + +describe("StageStepper", () => { + it("renders every stage with its date, then the unreached terminal step", () => { + render( + {}} + />, + ); + + expect(screen.getByText("New")).toBeInTheDocument(); + expect(screen.getByText("Sep 1")).toBeInTheDocument(); + expect(screen.getByText("Final / Onsite Interview")).toBeInTheDocument(); + expect(screen.getByText("Offer")).toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("marks the current stage for assistive tech", () => { + render( + {}} + />, + ); + + expect( + screen.getByRole("button", { name: /Final \/ Onsite Interview/ }), + ).toHaveAttribute("aria-current", "step"); + }); + + it("selects a step on click", async () => { + const onSelect = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: /Applied/ })); + + expect(onSelect).toHaveBeenCalledWith("b"); + }); + + it("renders the unreached terminal step as a non-interactive element", () => { + render( + {}} + />, + ); + + expect(screen.queryByRole("button", { name: /Offer/ })).toBeNull(); + }); + + // The rail truncates to one line; the full name stays reachable on hover + // and in the history list. Without this a long label wraps and pushes its + // dot out of line with the connectors either side. + it("keeps a long label on one line with the full name in a title", () => { + render( + {}} + />, + ); + + const label = screen.getByText("Final / Onsite Interview"); + expect(label).toHaveAttribute("title", "Final / Onsite Interview"); + expect(label.className).toContain("truncate"); + }); +}); diff --git a/src/components/myjobs/job-details/timeline/StageHistoryList.tsx b/src/components/myjobs/job-details/timeline/StageHistoryList.tsx new file mode 100644 index 000000000..c2050f9d7 --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageHistoryList.tsx @@ -0,0 +1,103 @@ +"use client"; +import { Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import type { JobStage, JobStageTypeRef } from "@/models/jobStage.model"; +import { formatStageDate } from "./stageDisplay"; + +type StageHistoryListProps = { + stages: JobStage[]; + selectedStageId: string | null; + currentStageId: string | null; + terminalTypes: JobStageTypeRef[]; + onSelect: (stageId: string) => void; + onAddStage: () => void; +}; + +export function StageHistoryList({ + stages, + selectedStageId, + currentStageId, + terminalTypes, + onSelect, + onAddStage, +}: StageHistoryListProps) { + return ( +
+

+ STAGE HISTORY +

+ + {/* The complete history lives here, not in the stepper: nothing is + reachable only by scrolling the stepper sideways. */} +
    + {stages.map((stage) => { + const isSelected = stage.id === selectedStageId; + const isCurrent = stage.id === currentStageId; + return ( +
  • + +
  • + ); + })} + + {terminalTypes.map((type) => ( +
  • + + + {type.label} + + — +
  • + ))} +
+ + +
+ ); +} diff --git a/src/components/myjobs/job-details/timeline/StageStepper.tsx b/src/components/myjobs/job-details/timeline/StageStepper.tsx new file mode 100644 index 000000000..0a8db7588 --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageStepper.tsx @@ -0,0 +1,109 @@ +"use client"; +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { JobStage, JobStageTypeRef } from "@/models/jobStage.model"; +import { formatStageDate } from "./stageDisplay"; + +type StageStepperProps = { + stages: JobStage[]; + selectedStageId: string | null; + currentStageId: string | null; + terminalTypes: JobStageTypeRef[]; + onSelect: (stageId: string) => void; +}; + +export function StageStepper({ + stages, + selectedStageId, + currentStageId, + terminalTypes, + onSelect, +}: StageStepperProps) { + const currentIndex = stages.findIndex((s) => s.id === currentStageId); + + return ( + // Container query, not a viewport breakpoint: the docked chat panel + // narrows this column without changing the viewport width. +
+
+ {stages.map((stage, index) => { + const isCurrent = stage.id === currentStageId; + const isComplete = currentIndex >= 0 && index < currentIndex; + const isSelected = stage.id === selectedStageId; + return ( +
+ {index > 0 && ( + + )} + +
+ ); + })} + + {terminalTypes.map((type) => ( +
+ +
+ + + {type.label} + + — +
+
+ ))} +
+
+ ); +} From c1a88843b087d1acbb19dc1e9be9d6c46ae51d14 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Sat, 19 Sep 2026 13:21:17 -0600 Subject: [PATCH 14/30] feat(timeline): add the stage detail panel with interviewers, prep list, inline asked toggles and in-place notes Co-Authored-By: Claude Opus 5 --- __tests__/StageDetailPanel.spec.tsx | 147 +++++++++++++++ .../job-details/timeline/StageDetailPanel.tsx | 169 ++++++++++++++++++ .../job-details/timeline/StageNotes.tsx | 72 ++++++++ .../job-details/timeline/StagePrepList.tsx | 140 +++++++++++++++ 4 files changed, 528 insertions(+) create mode 100644 __tests__/StageDetailPanel.spec.tsx create mode 100644 src/components/myjobs/job-details/timeline/StageDetailPanel.tsx create mode 100644 src/components/myjobs/job-details/timeline/StageNotes.tsx create mode 100644 src/components/myjobs/job-details/timeline/StagePrepList.tsx diff --git a/__tests__/StageDetailPanel.spec.tsx b/__tests__/StageDetailPanel.spec.tsx new file mode 100644 index 000000000..d3f9ff4df --- /dev/null +++ b/__tests__/StageDetailPanel.spec.tsx @@ -0,0 +1,147 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StageDetailPanel } from "@/components/myjobs/job-details/timeline/StageDetailPanel"; +import { + setPrepQuestionAsked, + setStageNotes, +} from "@/actions/jobStage.actions"; + +vi.mock("@/actions/jobStage.actions", () => ({ + setPrepQuestionAsked: vi.fn().mockResolvedValue({ success: true }), + removeStagePrepQuestion: vi.fn().mockResolvedValue({ success: true }), + unlinkStageInterviewer: vi.fn().mockResolvedValue({ success: true }), + setStageNotes: vi.fn().mockResolvedValue({ success: true }), +})); +vi.mock("@/lib/toast", () => ({ + toastActionResult: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +const interviewStage = { + id: "st1", + jobId: "j1", + stageTypeId: "t-int", + occurredAt: new Date(2026, 8, 24, 10, 0), + isCurrent: true, + outcome: "scheduled", + notes: "Bring laptop", + durationMins: 90, + format: "On-site", + location: "Acme HQ, Bldg 3", + createdAt: new Date(2026, 8, 2), + updatedAt: new Date(2026, 8, 2), + StageType: { + id: "t-int", + label: "Final / Onsite Interview", + value: "final / onsite interview", + statusId: "s-int", + sortOrder: 6, + Status: { id: "s-int", label: "Interview", value: "interview" }, + }, + interviewers: [ + { + id: "iv1", + stageId: "st1", + contactId: "c1", + Contact: { id: "c1", name: "Priya Nair", title: "VP Engineering", email: null, phone: null, linkedinUrl: null, Company: null }, + }, + ], + prepQuestions: [ + { id: "p1", stageId: "st1", questionId: "q1", asked: true, askedAt: new Date(), Question: { id: "q1", question: "Walk through your caching strategy", tags: [{ id: "tg1", label: "System Design", value: "system design", createdBy: "u1" }] } }, + { id: "p2", stageId: "st1", questionId: "q2", asked: false, askedAt: null, Question: { id: "q2", question: "Tell me about a conflict with a teammate", tags: [] } }, + ], +} as any; + +const nonInterviewStage = { + ...interviewStage, + id: "st2", + interviewers: [], + prepQuestions: [], + StageType: { + ...interviewStage.StageType, + label: "Applied", + Status: { id: "s-app", label: "Applied", value: "applied" }, + }, +}; + +const noop = () => {}; + +describe("StageDetailPanel", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows the stage name, the current-stage marker and the scheduled line", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "Final / Onsite Interview" })).toBeInTheDocument(); + expect(screen.getByText("CURRENT STAGE")).toBeInTheDocument(); + expect(screen.getByText(/Sep 24, 2026 · 10:00 AM · 90 min/)).toBeInTheDocument(); + }); + + it("shows interviewers, format, location and the asked tally for an interview stage", () => { + render( + , + ); + + expect(screen.getByText("Priya Nair")).toBeInTheDocument(); + expect(screen.getByText(/On-site/)).toBeInTheDocument(); + expect(screen.getByText(/Acme HQ, Bldg 3/)).toBeInTheDocument(); + expect(screen.getByText("1 of 2 asked")).toBeInTheDocument(); + expect(screen.getByText("System Design")).toBeInTheDocument(); + }); + + it("hides interviewers and the prep list on a non-interview stage", () => { + render( + , + ); + + expect(screen.queryByText(/INTERVIEWERS/)).toBeNull(); + expect(screen.queryByText(/PREP LIST/)).toBeNull(); + // Notes are a live textarea, so this is a value, not page text. + expect(screen.getByLabelText("NOTES")).toHaveValue("Bring laptop"); + }); + + it("marks a question asked inline, with no dialog and no save step", async () => { + render( + , + ); + + await userEvent.click( + screen.getByRole("checkbox", { name: /Tell me about a conflict with a teammate/ }), + ); + + expect(setPrepQuestionAsked).toHaveBeenCalledWith("p2", true); + }); + + // The artboard's inline notes editor. Save appears only once the text has + // actually changed, so the resting panel is not cluttered with dead buttons. + it("edits notes in place, showing Save only once the text changes", async () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: "Save Changes" })).toBeNull(); + + await userEvent.type(screen.getByLabelText("NOTES"), " Ask about on-call."); + await userEvent.click(screen.getByRole("button", { name: "Save Changes" })); + + expect(setStageNotes).toHaveBeenCalledWith( + "st1", + "Bring laptop Ask about on-call.", + ); + }); + + it("reverts the draft on Cancel without calling the action", async () => { + render( + , + ); + + await userEvent.type(screen.getByLabelText("NOTES"), " scratch"); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.getByLabelText("NOTES")).toHaveValue("Bring laptop"); + expect(setStageNotes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/myjobs/job-details/timeline/StageDetailPanel.tsx b/src/components/myjobs/job-details/timeline/StageDetailPanel.tsx new file mode 100644 index 000000000..ed9350d03 --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageDetailPanel.tsx @@ -0,0 +1,169 @@ +"use client"; +import { useTransition } from "react"; +import { CalendarDays, Monitor, Pencil, UserPlus, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { toastActionResult } from "@/lib/toast"; +import { unlinkStageInterviewer } from "@/actions/jobStage.actions"; +import { STAGE_OUTCOMES } from "@/lib/constants"; +import type { JobStage } from "@/models/jobStage.model"; +import { + formatStageDateTime, + isInterviewStage, + stageInitials, +} from "./stageDisplay"; +import { StagePrepList } from "./StagePrepList"; +import { StageNotes } from "./StageNotes"; + +type StageDetailPanelProps = { + stage: JobStage; + isCurrent: boolean; + onEdit: () => void; + onLinkInterviewers: () => void; + onAddPrepQuestions: () => void; + onChanged: () => void; +}; + +export function StageDetailPanel({ + stage, + isCurrent, + onEdit, + onLinkInterviewers, + onAddPrepQuestions, + onChanged, +}: StageDetailPanelProps) { + const [, startTransition] = useTransition(); + const interview = isInterviewStage(stage); + const outcomeLabel = STAGE_OUTCOMES.find((o) => o.value === stage.outcome)?.label; + + const unlink = (linkId: string) => { + startTransition(async () => { + const res = await unlinkStageInterviewer(linkId); + toastActionResult(res, { + success: "Interviewer unlinked from this stage", + onSuccess: onChanged, + }); + }); + }; + + return ( +
+
+
+

{stage.StageType.label}

+ {isCurrent && ( + + CURRENT STAGE + + )} +
+ +
+ +

+ + {formatStageDateTime(stage.occurredAt, stage.durationMins)} +

+ + {interview && ( + <> +
+
+
+
+ + INTERVIEWERS + + +
+ {stage.interviewers.length === 0 ? ( +

+ No interviewers linked yet. +

+ ) : ( +
    + {stage.interviewers.map((link) => ( +
  • + + {stageInitials(link.Contact.name)} + + + + {link.Contact.name} + + {link.Contact.title && ( + + {link.Contact.title} + + )} + + +
  • + ))} +
+ )} +
+ +
+ + FORMAT + +

+ + {[stage.format, stage.location].filter(Boolean).join(" · ") || "Not set"} +

+
+
+ +
+ + + )} + +
+
+
+ + OUTCOME + +

+ {outcomeLabel ? ( + {outcomeLabel} + ) : ( + Not recorded + )} +

+
+ +
+
+ ); +} diff --git a/src/components/myjobs/job-details/timeline/StageNotes.tsx b/src/components/myjobs/job-details/timeline/StageNotes.tsx new file mode 100644 index 000000000..258e9c8de --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageNotes.tsx @@ -0,0 +1,72 @@ +"use client"; +import { useEffect, useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { toastActionResult } from "@/lib/toast"; +import { setStageNotes } from "@/actions/jobStage.actions"; +import type { JobStage } from "@/models/jobStage.model"; + +type StageNotesProps = { + stage: JobStage; + onChanged: () => void; +}; + +export function StageNotes({ stage, onChanged }: StageNotesProps) { + const [draft, setDraft] = useState(stage.notes ?? ""); + const [saving, startTransition] = useTransition(); + + // Selecting another stage in the history list re-renders this component + // with a different stage, so the draft has to follow the id, not mount. + useEffect(() => { + setDraft(stage.notes ?? ""); + }, [stage.id, stage.notes]); + + const dirty = draft !== (stage.notes ?? ""); + + const save = () => { + startTransition(async () => { + const res = await setStageNotes(stage.id, draft); + toastActionResult(res, { success: "Note saved", onSuccess: onChanged }); + }); + }; + + return ( +
+ +