From df950c72538c48ffa1ca85f59d52e4fbb07e0bff Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 23:49:43 +0530 Subject: [PATCH] feat(crewing): ex-hand recognition (B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AC1: addCandidate recognizes a returning hand re-entered as a fresh candidate — matched to their existing EX_HAND pool record by email (preferred) or exact name — and reuses that row instead of creating a duplicate, preserving tour history/documents/bank. Audited CANDIDATE_UPDATED { exHandRecognized: true }. - AC2: the Candidates list sorts ex-hands above new candidates by default (stable, preserving createdAt order within each group). - AC3: the candidate detail "Returning crew" callout now renders the matched member's actual tour history (ExperienceRecord) and documents on file. candidates.test.ts covers email/name recognition, the no-match path, and the ex-hand-first page ordering. Co-Authored-By: Claude Opus 4.8 --- .../(portal)/crewing/candidates/[id]/page.tsx | 46 +++++++++++++++-- .../(portal)/crewing/candidates/actions.ts | 39 ++++++++++++++ App/app/(portal)/crewing/candidates/page.tsx | 4 ++ App/tests/integration/candidates.test.ts | 51 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/App/app/(portal)/crewing/candidates/[id]/page.tsx b/App/app/(portal)/crewing/candidates/[id]/page.tsx index 2ea86f8..16884e5 100644 --- a/App/app/(portal)/crewing/candidates/[id]/page.tsx +++ b/App/app/(portal)/crewing/candidates/[id]/page.tsx @@ -21,7 +21,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise< const { id } = await params; const c = await db.crewMember.findUnique({ where: { id }, - include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } }, + include: { + appliedRank: { select: { name: true } }, + currentRank: { select: { name: true } }, + // B3 AC3 — pull the returning hand's history so the callout shows real records. + experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } }, + documents: { orderBy: { createdAt: "desc" }, select: { id: true, docType: true, expiryDate: true } }, + }, }); if (!c) notFound(); @@ -53,8 +59,42 @@ export default async function CandidateDetailPage({ params }: { params: Promise< {c.source === "EX_HAND" && (
- Returning crew. Prior documents, bank details and tour history are on file from earlier - assignments; the interview may be waived with Manager approval (recruitment pipeline — next phase). + Returning crew. The interview may be waived with Manager approval.{" "} + {c.experienceRecords.length === 0 && c.documents.length === 0 ? ( + No prior records are on file yet. + ) : ( + Prior records on file from earlier assignments: + )} + + {c.experienceRecords.length > 0 && ( +
+

Tour history

+
    + {c.experienceRecords.map((e) => ( +
  • + {e.rank?.name ?? "—"} + {e.vesselType ? ` · ${e.vesselType}` : ""} + {e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""} + {e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `–${e.toDate.getFullYear()}` : ""})` : ""} +
  • + ))} +
+
+ )} + + {c.documents.length > 0 && ( +
+

Documents on file

+
+ {c.documents.map((doc) => ( + + {doc.docType} + {doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""} + + ))} +
+
+ )}
)} diff --git a/App/app/(portal)/crewing/candidates/actions.ts b/App/app/(portal)/crewing/candidates/actions.ts index 5d7c928..63972ca 100644 --- a/App/app/(portal)/crewing/candidates/actions.ts +++ b/App/app/(portal)/crewing/candidates/actions.ts @@ -76,6 +76,45 @@ export async function addCandidate(formData: FormData): Promise { const d = parsed.data; const { type, status } = derive(d.source); + // B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh + // candidate (not already tagged EX_HAND) is matched to their existing EX_HAND + // pool record by a stable key — email when given, else an exact name match — + // and the SAME row is reused (so their tour history, documents and bank stay on + // file) rather than creating a duplicate. (Heuristic: with no DOB on file a + // name-only match can in theory collide; email is preferred when available.) + if (d.source !== "EX_HAND") { + const match = await db.crewMember.findFirst({ + where: { + status: "EX_HAND", + ...(d.email + ? { email: { equals: d.email, mode: "insensitive" } } + : { name: { equals: d.name, mode: "insensitive" } }), + }, + select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true }, + }); + if (match) { + const updated = await db.crewMember.update({ + where: { id: match.id }, + data: { + // Keep EX_HAND type/status; refresh the application's details, never + // discarding prior history (take the larger recorded experience). + appliedRankId: d.appliedRankId || match.appliedRankId, + currentRankId: d.currentRankId || match.currentRankId, + email: d.email || match.email, + phone: d.phone || match.phone, + notes: d.notes || match.notes, + experienceMonths: Math.max(d.experienceMonths, match.experienceMonths), + vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience, + actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } }, + }, + }); + const cvKey = await storeCv(formData, updated.id); + if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } }); + revalidatePath(LIST_PATH); + return { ok: true, id: updated.id }; + } + } + const candidate = await db.crewMember.create({ data: { name: d.name, diff --git a/App/app/(portal)/crewing/candidates/page.tsx b/App/app/(portal)/crewing/candidates/page.tsx index 80adfae..065ef37 100644 --- a/App/app/(portal)/crewing/candidates/page.tsx +++ b/App/app/(portal)/crewing/candidates/page.tsx @@ -46,5 +46,9 @@ export default async function CandidatesPage() { hasCv: Boolean(c.cvKey), })); + // B3 AC2 — ex-hands (proven crew) surface above new candidates by default. + // Stable sort preserves the createdAt-desc order within each group. + rows.sort((a, b) => Number(b.status === "EX_HAND") - Number(a.status === "EX_HAND")); + return ; } diff --git a/App/tests/integration/candidates.test.ts b/App/tests/integration/candidates.test.ts index 0baafe5..915ad8b 100644 --- a/App/tests/integration/candidates.test.ts +++ b/App/tests/integration/candidates.test.ts @@ -6,14 +6,21 @@ * its CrewAction rows) wholesale — no pre-existing rows to preserve. */ import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import React from "react"; +// The list page's JSX compiles to classic React.createElement in the node runner. +(globalThis as unknown as { React: typeof React }).React = React; vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() })); vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); +// We read the page element's props directly; the client component is irrelevant. +vi.mock("@/app/(portal)/crewing/candidates/candidates-manager", () => ({ CandidatesManager: () => null })); import { auth } from "@/auth"; import { db } from "@/lib/db"; import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions"; +import CandidatesPage from "@/app/(portal)/crewing/candidates/page"; import { makeSession, getSeedUser, fd } from "./helpers"; import type { Role } from "@prisma/client"; @@ -88,6 +95,50 @@ describe("addCandidate", () => { }); }); +describe("ex-hand recognition + ordering (B3)", () => { + it("recognizes a returning hand by email and reuses the same row (AC1)", async () => { + as(managerId, "MANAGER"); + await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" })); + const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } }); + + // Re-applies as a fresh careers candidate with the same email → recognized. + const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId })); + expect("ok" in res && res.id).toBe(exhand.id); + expect(await db.crewMember.count()).toBe(1); // no duplicate row + + const after = await db.crewMember.findUniqueOrThrow({ where: { id: exhand.id }, include: { actions: true } }); + expect(after.status).toBe("EX_HAND"); + expect(after.appliedRankId).toBe(rankId); + expect(after.experienceMonths).toBe(120); // prior history preserved (max) + expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true); + }); + + it("recognizes a returning hand by exact name when no email is given (AC1)", async () => { + as(managerId, "MANAGER"); + await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" })); + const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive + const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } }); + expect("ok" in res && res.id).toBe(exhand.id); + expect(await db.crewMember.count()).toBe(1); + }); + + it("does not match a different person → creates a new candidate", async () => { + as(managerId, "MANAGER"); + await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" })); + await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" })); + expect(await db.crewMember.count()).toBe(2); + }); + + it("lists ex-hands above new candidates by default (AC2)", async () => { + as(managerId, "MANAGER"); + await addCandidate(fd({ name: "New First", source: "CAREERS" })); + await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" })); + const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } }; + expect(el.props.candidates[0].status).toBe("EX_HAND"); + expect(el.props.candidates[0].name).toBe("Ex Second"); + }); +}); + describe("updateCandidate", () => { it("edits fields and writes a CANDIDATE_UPDATED action", async () => { as(managerId, "MANAGER");