Facility Pipeline — implementation plan
The build checklist for VS Code Claude. Grounded in this repo's patterns. Migration number, component names, and tooling rules are verified against the tree.
0. Status legend
✅ done & verified · ⬜ to build. The pure core (Phase 1) is already in the tree.
1. File manifest
Migration & types
| Path | Status | Purpose |
|---|---|---|
src/db/migrations/0053_facility_pipeline.sql | ✅ | Additive DDL (the repo already has 0050–0052). Parses against the PG grammar; includes review fixes. Not yet applied. |
src/db/types.ts | ⬜ | Regenerate after applying 0053 (supabase gen types typescript). Biome-ignored, but tsc still checks it. |
Pure library (no I/O) — DONE
| Path | Status | Purpose |
|---|---|---|
src/lib/pipeline/types.ts | ✅ | Domain types (RoleFamily, FacilityScoreInputs, MatchCandidate, …). |
src/lib/pipeline/job-signal.ts | ✅ | classifyRoleFamily, scoreUrgency, ROLE_WEIGHT. |
src/lib/pipeline/score.ts | ✅ | computeFacilityScore (revised model), deriveTier, DEFAULT_TIER_CUTOFFS. |
src/lib/pipeline/match-facility.ts | ✅ | nameSimilarity, resolveFacility, tokens. |
tests/lib/pipeline/{job-signal,score,match-facility}.test.ts | ✅ | Vitest unit tests (run pnpm test). |
Zod schemas, queries, actions
| Path | Status | Purpose |
|---|---|---|
src/types/schemas/pipeline.ts | ⬜ | Zod for every action input; ids z.string().uuid(); export …Input. |
src/types/schemas/facility.ts | ⬜ (edit) | Widen facility_contacts.role enum to the new vocabulary (see §3). |
src/db/queries/pipeline.ts | ⬜ | DB I/O (client as first arg): upsertJobRaw, upsertJobSignal, listSignalsForInbox, listUnmatchedSignals, listPipelineFacilities, updateFacilityScore, listDueTasks, insertFollowUpTask, completeTask, listSequences. |
src/server/actions/pipeline.ts | ⬜ | 'use server' actions (auth → Zod → company-scope → query → audit → revalidate). |
AI, cron, script
| Path | Status | Purpose |
|---|---|---|
src/server/ai/extractors/job-signal.ts | ⬜ | extractJobSignalFromPosting(raw) — text orchestrator, Zod-validated; fallback for unstructured input. |
src/server/ai/extractors/follow-up-email.ts | ⬜ | draftFollowUpEmail(ctx) — returns a draft only; tone guardrail in the prompt. |
src/app/api/cron/job-signals/route.ts | ⬜ | POST, cron-secret gated (ADR-0024) → fetch → ingest. |
src/app/api/cron/follow-ups/route.ts | ⬜ | POST, cron-secret gated → materialize due tasks (idempotent) → draft emails. |
scripts/ingest-job-signals.mjs | ⬜ | CLI mirror (--dry default, --live, --commit); CareerOneStop + schema.org; import-prospects.mjs pattern. A working prototype exists in the prior deliverables folder. |
UI (extends /admin/crm)
| Path | Status | Purpose |
|---|---|---|
src/app/admin/crm/page.tsx | ⬜ (edit) | Add tabs: Signal Inbox · Pipeline Board (existing) · Today's Follow-ups · Sequences. Use Tabs. |
src/app/admin/crm/loading.tsx / error.tsx | ⬜ | Skeleton + error boundary (UX guidelines). |
src/components/pipeline/SignalInbox.tsx | ⬜ | Table of new/unmatched signals; row actions. |
src/components/pipeline/TodayFollowUps.tsx | ⬜ | Due follow_up_tasks grouped by channel. |
src/components/pipeline/SequenceManager.tsx | ⬜ | Sequence templates + enrollments. |
src/components/pipeline/ProspectDiscovery.tsx | ⬜ | High-score facilities with no open posting. |
src/components/pipeline/ScoreBadge.tsx | ⬜ | Tier chip + score + "as of" provenance. |
src/components/admin/Facility360*.tsx | ⬜ (edit existing facility detail) | Score breakdown, contacts-by-persona, timeline, signals, cadence. |
| Modals | ⬜ | 13 modals via Dialog/confirmDanger — see 04-ux-spec.md. |
Nav & ADR
| Path | Status | Purpose |
|---|---|---|
src/components/admin/AdminNav.tsx | ⬜ (edit) | Rename the existing CRM item to "Pipeline"/"BD Pipeline"; do not add a competing item. |
docs/adr/0040-facility-pipeline.md | ✅ | Decision record. |
2. Key signatures
Server actions follow the repo's crm.ts pattern — (_prev: State, formData: FormData) => Promise<State>,
auth via getCurrentAdmin, Zod-validated, company-scoped, audited. (Confirm the exact ActionResult
shape in src/server/actions/crm.ts before writing — match it.)
// src/server/actions/pipeline.ts
ingestJobSignals(payloads): summary // from cron route / script; upserts job_raw + job_signals
matchSignalToFacility(signalId, facilityId) // resolve a manual-queue signal; optional add facility_alias
raiseLeadFromSignal(signalId) // creates/refreshes a crm_lead; active-client → upsell track
moveLeadStage(leadId, stage|status) // reuse the existing CRM board action if present
logActivity(targetType, targetId, type, notes, followUpDate?)
enrollInSequence(sequenceId, facilityId|leadId, ownerId?) // blocks if contact-enrichment gate unmet
completeFollowUpTask(taskId, status, completedAt?) // done|skipped|snoozed(+until)
sendFollowUpEmailDraft(taskId, facilityId, templateKey) // drafts into outbound_emails; never sends
refreshFacilityScore(facilityId) // computeFacilityScore → optimistic update
Pure lib (already implemented — call these):
classifyRoleFamily(title, body?) => RoleFamily
scoreUrgency(posting) => 0|1|2|3|4
computeFacilityScore(inputs, cutoffs?) => { score, tier, breakdown, medicaidMultiplier }
resolveFacility(posting, candidates, opts?) => { facility|null, confidence, reason, runnerUp? }
3. Repo-specific gotchas (verified)
- Migration number is
0053—0050–0052already exist. (Done.) - Type-regen ordering: apply
0053→supabase gen types→ then write query/action code. Until then, new-table code won't typecheck. The pure lib has no DB types, so Phase 1 stands alone. exactOptionalPropertyTypes: true: for nullable DB columns use?? null(ascrm.tsdoes); for truly optional insert fields, spread-omit:...(x != null && { field: x }). Never assignfield: value ?? undefined.noUncheckedIndexedAccess: true: array access isT | undefined— guard (arr[0]?.x) or use.find(). (The pure lib already complies.)- Biome: single quotes, semicolons, trailing commas, 100-col,
import typefor type-only imports.noConsoleLogis warn insrc/— use@/lib/log, notconsole.log(scripts are exempt). - Guardrails (
scripts/guardrails.mjs): never name an envNEXT_PUBLIC_*SECRET/SERVICE_KEY/…; never touch a Wise funding path. New secrets (CAREERONESTOP_API_KEY, …) are server-only insrc/server/env.ts,.optional()sonext builddoesn't fail, documented in.env.example. - Tests live in
tests/(not colocated);import { describe, expect, it } from 'vitest';server-onlyis shimmed invitest.config.ts. - Component reuse (verified to exist in
src/components/ui/):Table,Badge,Dialog,EmptyState,Toast(Toaster+notify),DangerConfirm(confirmDanger),PhoneInput,Tabs,Spinner,Alert,Card,Button,SlideOver,SortHeader,form.tsx. Shell:src/components/admin/{AdminShell,AdminNav}.tsx(white sidebar — don't reinvent it). facility_contacts.roleZod enum insrc/types/schemas/is currently['billing','admin','clinical']; widen toadministrator|don|mds_coordinator|regional_reimbursement|cfo|owner|admin|clinical|billing|otheralongside the migration (the DB column is free text, so this is additive).
4. Build order
- Phase 1 — Pure core + tests. ✅ Done (
src/lib/pipeline/*,tests/lib/pipeline/*).pnpm testgreen. - Phase 2 — Schema + types. Apply
0053(dev branch) → regensrc/db/types.ts→ widen contact-role schema →pnpm typecheck. - Phase 3 — Ingestion.
src/types/schemas/pipeline.ts, env vars,src/db/queries/pipeline.ts, the job-signal extractor,ingestJobSignals/matchSignalToFacility/raiseLeadFromSignal,/api/cron/job-signals,scripts/ingest-job-signals.mjs(dry default).pnpm build. - Phase 4 — Scoring + Pipeline UI.
refreshFacilityScore;SignalInbox,ScoreBadge,ProspectDiscovery, Facility 360;/admin/crmtabs +loading.tsx/error.tsx. Verify every component state (empty/loading/error) and the modal inventory. - Phase 5 — Follow-up engine + Sequences.
enrollInSequence/completeFollowUpTask/sendFollowUpEmailDraft,/api/cron/follow-ups,TodayFollowUps,SequenceManager. - Phase 6 — CI green.
pnpm check && pnpm typecheck && pnpm guardrails && pnpm test && pnpm build. Branchfeat/facility-pipeline, conventional commits (feat:), PR tomain.