{
  "slug": "contractor-portal",
  "spec_file": "docs/specs/2026-05-31-contractor-portal.md",
  "findings": [
    {
      "id": "contractor-portal-001",
      "severity": "P0",
      "type": "security",
      "summary": "contractor_portal_sessions table absent — magic-link auth foundation missing",
      "spec_ref": "Data Model — CREATE TABLE contractor_portal_sessions",
      "code_ref": "packages/db/migrations/*.sql",
      "evidence": "grep contractor_portal_sessions across packages/db/migrations/*.sql returns zero matches; packages/db/src/schema/index.ts has no contractor-portal export; no packages/db/src/schema/contractor-portal.ts file.",
      "repro": "N/A — table and Drizzle schema not created.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-002",
      "severity": "P0",
      "type": "security",
      "summary": "Contractor JWT + contractorAuthMiddleware absent — no contractor-scoped auth layer",
      "spec_ref": "Contractor JWT; Architecture — All portal API routes require contractor JWT via contractorAuthMiddleware",
      "code_ref": "packages/auth/src/index.ts:1",
      "evidence": "packages/auth/src/index.ts exports signSession/verifySession only; grep signContractorSession|verifyContractorSession|contractorAuthMiddleware|zync_contractor across packages/auth returns zero matches. No packages/auth/src/contractor-session.ts or middleware/contractor-auth.ts.",
      "repro": "N/A — contractor JWT mint/verify and middleware not implemented.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-003",
      "severity": "P0",
      "type": "security",
      "summary": "All /contractor-portal/redeem and /contractor-portal/api/* routes absent from zync-api",
      "spec_ref": "API (contractor-scoped routes); Staff API Extension",
      "code_ref": "apps/zync-api/src/routes/index.ts:1",
      "evidence": "grep contractor-portal|portal-invite in apps/zync-api/src returns zero route mounts. apps/zync-api/src/routes/contractors/router.ts documents staff contractor CRUD only — no portal-invite or contractor-portal-api files exist.",
      "repro": "GET /contractor-portal/redeem?token=… → 404; GET /contractor-portal/api/me → 404; POST /api/contractors/:id/portal-invite → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-004",
      "severity": "P0",
      "type": "security",
      "summary": "Portal UI authenticates via staff session cookie — external contractors (no users row) cannot access",
      "spec_ref": "Overview — contractor authentication via magic link; Data Model — Contractors do not have rows in users table",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:105",
      "evidence": "File header comment states 'Uses existing tenant-user session + standard API endpoints'. All fetch calls use credentials:include against /api/time, /api/payouts, /api/projects (lines 105-125) — staff authMiddleware routes, not contractor-portal/api. No GET /contractor-portal/api/me bootstrap or zync_contractor cookie.",
      "repro": "External contractor without staff login navigates /contractor-portal → Shell redirects to /login (Shell.tsx:92-94). Magic link flow cannot complete (redeem route absent).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-005",
      "severity": "P0",
      "type": "security",
      "summary": "Portal routes mounted inside Shell — requires staff sidebar session, not dedicated external layout",
      "spec_ref": "Portal Pages — dedicated layout (no sidebar, minimal nav, tenant logo + name)",
      "code_ref": "apps/zync-app/src/main.tsx:88",
      "evidence": "contractor-portal routes registered in moduleRoutes (routes/index.tsx:137-138) rendered under <Route element={<Shell />}> (main.tsx:88-91). Shell.tsx:92-94 redirects unauthenticated users to /login via /api/auth/me staff session check.",
      "repro": "Unauthenticated contractor cannot reach portal; authenticated staff sees full app chrome (Header + Sidebar via Shell.tsx:107+).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-006",
      "severity": "P0",
      "type": "security",
      "summary": "Portal dashboard loads tenant-wide payout ledger — not scoped to authenticated contractor",
      "spec_ref": "Payout Bills — read-only view of own payout bills; GET /contractor-portal/api/bills",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:116",
      "evidence": "fetchPayouts calls GET /api/payouts?limit=50 (line 117). payoutRoutes.get('/') uses listAllPayouts(db, session.tid, …) with no contractorId unless query param supplied (apps/zync-api/src/routes/contractors/payouts.ts:21-34; packages/db/src/queries/contractors.ts:1018-1021). Portal UI never passes contractorId.",
      "repro": "Staff user with payouts:read opens /contractor-portal → Pending Payouts card aggregates bills for all contractors in tenant.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-007",
      "severity": "P0",
      "type": "security",
      "summary": "Log-time project picker loads all active tenant projects — not contractor_assignments scoped",
      "spec_ref": "Log Time Form — only assigned projects; GET /contractor-portal/api/me returns assigned projects",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:122",
      "evidence": "fetchProjects calls GET /api/projects?status=active&limit=100 (line 123). No call to contractor assignments or /contractor-portal/api/me. Same pattern in time.tsx:83-86.",
      "repro": "Open Log Time on /contractor-portal → project select lists every active tenant project, including unassigned ones.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-008",
      "severity": "P0",
      "type": "security",
      "summary": "Portal time submission creates staff manual entries (user_id=session.sub, source=manual) — not contractor_portal",
      "spec_ref": "Time Entry Submission — contractor_id=JWT id, user_id=NULL, source=contractor_portal, approval_status from flag",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:135",
      "evidence": "logTime POSTs to /api/time (line 135). timeRoutes.post('/') calls logManualEntry(db, session.tid, session.sub, …) (apps/zync-api/src/routes/time.ts:494-501). logManualEntry inserts userId + source:'manual' (packages/db/src/queries/time.ts:464-471); contractor_id not set.",
      "repro": "Staff user logs time from portal → time_entries row has user_id=<staff UUID>, contractor_id NULL, source='manual'.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Staff POST /api/contractors/:id/portal-invite not implemented",
      "spec_ref": "Staff: Send Portal Invite; Staff API — POST /api/contractors/:id/portal-invite",
      "code_ref": "apps/zync-api/src/routes/contractors/router.ts:1",
      "evidence": "Router file header lists all routes (lines 5-23); portal-invite absent. grep portal-invite in apps/zync-api returns zero matches.",
      "repro": "POST /api/contractors/{id}/portal-invite → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-010",
      "severity": "P1",
      "type": "missing",
      "summary": "Staff GET /api/contractors/:id/portal-invite/status not implemented",
      "spec_ref": "Staff API — GET /api/contractors/:id/portal-invite/status → { hasPortalAccess, lastSeen }",
      "code_ref": "apps/zync-api/src/routes/contractors/router.ts:1",
      "evidence": "grep portal-invite/status across apps/zync-api returns zero matches.",
      "repro": "GET /api/contractors/{id}/portal-invite/status → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-011",
      "severity": "P1",
      "type": "missing",
      "summary": "Portal session query helpers absent (createPortalSession, findValidPortalSession, markPortalSessionUsed, getPortalAccessStatus)",
      "spec_ref": "Plan Task 4 — packages/db/src/queries/contractor-portal-sessions.ts",
      "code_ref": "packages/db/src/queries/index.ts:554",
      "evidence": "packages/db/src/queries/index.ts exports contractor payout helpers from ./contractors only; grep contractor-portal-sessions|createPortalSession|findValidPortalSession across packages/db returns zero matches.",
      "repro": "N/A — query module not created.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-012",
      "severity": "P1",
      "type": "missing",
      "summary": "Contractor-portal time CRUD query helpers absent",
      "spec_ref": "Plan Task 5 — packages/db/src/queries/contractor-portal-time.ts",
      "code_ref": "packages/db/src/queries/",
      "evidence": "grep getContractorProfile|listContractorTimeEntries|createContractorTimeEntry|listContractorBills|resolveContractorApprovalRequirement across packages/db returns zero matches. No contractor-portal-time.ts file.",
      "repro": "N/A — contractor-scoped time/bill queries not implemented.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-013",
      "severity": "P1",
      "type": "missing",
      "summary": "Shared contractor-portal types absent from packages/types",
      "spec_ref": "Plan Task 3 — ContractorPortalProfile, ContractorTimeEntryInput, ContractorTimeEntryRow, ContractorPayoutBillRow, ContractorSessionPayload",
      "code_ref": "packages/types/src/",
      "evidence": "grep ContractorPortalProfile|ContractorTimeEntryInput|ContractorPayoutBillRow|ContractorSessionPayload across packages/types returns zero matches. No packages/types/src/contractor-portal.ts.",
      "repro": "N/A — shared types not exported.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-014",
      "severity": "P1",
      "type": "missing",
      "summary": "Magic-link redeem page and expired-link page absent",
      "spec_ref": "Plan Task 5 — GET /contractor-portal/redeem; Plan Task 7 — 401 from /me redirects to expired-link page",
      "code_ref": "apps/zync-app/src/routes/index.tsx:137",
      "evidence": "moduleRoutes registers only contractor-portal and contractor-portal/time (lines 137-138). grep redeem|expired-link in apps/zync-app/src/routes/contractor-portal returns zero matches.",
      "repro": "Navigate /contractor-portal/redeem?token=… → no SPA route; API redeem also absent.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-015",
      "severity": "P1",
      "type": "missing",
      "summary": "Staff Portal access panel on contractor detail absent",
      "spec_ref": "Staff: Send Portal Invite — contractor detail page portal access panel",
      "code_ref": "apps/zync-app/src/routes/contractors/$id.tsx:206",
      "evidence": "Contractor detail InfoPanel action area shows only 'Initiate payout →' link (line 208-210). grep portal|Portal access|portal-invite in apps/zync-app/src/routes/contractors returns zero matches. No PortalAccessPanel.tsx.",
      "repro": "Open /contractors/:id → no 'Portal access: Not enabled' or 'Send portal invite' UI.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-016",
      "severity": "P1",
      "type": "missing",
      "summary": "Dedicated Payout Bills page absent",
      "spec_ref": "Portal Pages — Payout history read-only list",
      "code_ref": "apps/zync-app/src/routes/index.tsx:137",
      "evidence": "moduleRoutes has contractor-portal and contractor-portal/time only. No PayoutBillsPage.tsx or payout-bills route. Dashboard embeds payout summary via wrong /api/payouts call only.",
      "repro": "No /contractor-portal/bills or equivalent payout history page.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-017",
      "severity": "P1",
      "type": "missing",
      "summary": "Edit/delete pending entries UI absent — no PATCH/DELETE affordances",
      "spec_ref": "Time Log History — edit/delete while approval_status=pending; PATCH/DELETE /contractor-portal/api/time/:id",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/time.tsx:307",
      "evidence": "TimeEntryRow component is read-only (lines 307-351). grep PATCH|DELETE|edit|delete in contractor-portal routes returns no mutation handlers. No calls to /contractor-portal/api/time/:id.",
      "repro": "View pending entry on /contractor-portal/time → no edit or delete controls.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-018",
      "severity": "P1",
      "type": "missing",
      "summary": "CSV export endpoint and UI link absent",
      "spec_ref": "Time Log History — Export CSV; Plan Task 6 — GET /contractor-portal/api/time/export.csv",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/time.tsx:1",
      "evidence": "grep export.csv|Export CSV in apps/zync-app/src/routes/contractor-portal returns zero matches. No contractor-portal-export query file in packages/db.",
      "repro": "No export control on time history page; GET /contractor-portal/api/time/export.csv → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-019",
      "severity": "P1",
      "type": "missing",
      "summary": "Plan module structure absent — no modules/contractor-portal layout, api hooks, or TanStack Query hooks",
      "spec_ref": "Plan Task 7 — ContractorPortalLayout.tsx, api.ts, index.tsx under modules/contractor-portal",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:1",
      "evidence": "grep modules/contractor-portal across apps/zync-app returns zero matches. Implementation is inline in routes/contractor-portal/*.tsx without useContractorProfile/useContractorBills hooks.",
      "repro": "N/A — dedicated portal module not created per plan.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-020",
      "severity": "P1",
      "type": "missing",
      "summary": "API and E2E tests for contractor portal absent",
      "spec_ref": "Plan Task 10 — apps/zync-api/test/contractor-portal.test.ts, apps/zync-app/test/contractor-portal.e2e.ts",
      "code_ref": "apps/zync-api/test/",
      "evidence": "grep contractor-portal across **/*.{spec,test,e2e}.ts returns zero matches. No contractor-portal.test.ts or contractor-portal.e2e.ts files.",
      "repro": "N/A — test files not created.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-021",
      "severity": "P2",
      "type": "diverges",
      "summary": "Log Time form missing Task select — spec requires project + task from assigned project tasks",
      "spec_ref": "Log Time Form — Task [Development ▾] from tasks on assigned projects",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:226",
      "evidence": "LogTimeDialog renders Project, Date, Hours, Minutes, Description, Billable only (lines 226-315). No task_id field or task fetch. POST body to /api/time omits taskId unless added manually.",
      "repro": "Open Log Time dialog → no Task dropdown.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-022",
      "severity": "P2",
      "type": "diverges",
      "summary": "Time history uses date-range filters not month dropdown with approval_status filter and totals row",
      "spec_ref": "Time Log History — [This month ▾], Status column, Total/Approved/Pending totals",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/time.tsx:423",
      "evidence": "Filters are project + from/to date inputs (lines 423-471). Summary shows entry count + total seconds only (lines 474-481). No month param, no approval_status filter, no Approved/Pending breakdown.",
      "repro": "Open /contractor-portal/time → no month selector or status-filtered totals per spec wireframe.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-023",
      "severity": "P2",
      "type": "diverges",
      "summary": "Dashboard layout diverges — no tenant logo/name header or Hello {contractor name}",
      "spec_ref": "Portal Pages — Dashboard wireframe (tenant logo + name, Hello Dana Contractor, This week hours/pending)",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:383",
      "evidence": "Header renders static 'Contractor Portal' title (lines 383-388). No tenant logo, tenant name, or contractor greeting from /contractor-portal/api/me. Summary cards show Hours This Week / Billable / Pending Payouts — not spec's 'Hours logged' + 'Pending review: N entries'.",
      "repro": "Open /contractor-portal → generic English header, no personalized contractor/tenant branding.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-024",
      "severity": "P2",
      "type": "diverges",
      "summary": "Portal does not gate on tenant_settings.contractor_portal_enabled or Business+ tier",
      "spec_ref": "contractor-settings — portal auth layer gates on contractor_portal_enabled; Business+ coming-soon when disabled tier",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:346",
      "evidence": "grep contractor_portal_enabled in apps/zync-app/src/routes/contractor-portal returns zero matches. Flag exists in tenant_settings (packages/db/src/schema/tenants.ts:148) and settings UI (settings/contractors.tsx) but portal routes never read it.",
      "repro": "Set contractor_portal_enabled=false in settings → /contractor-portal still reachable by staff session with no gate.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-025",
      "severity": "P2",
      "type": "diverges",
      "summary": "Portal time list uses staff /api/time scoped to session.sub user_id — not contractor_id entries",
      "spec_ref": "GET /contractor-portal/api/time — own time entries filtered by contractor_id = JWT.sub",
      "code_ref": "apps/zync-api/src/routes/time.ts:252",
      "evidence": "GET /api/time calls listEntries with userId: targetUserId defaulting to session.sub (lines 244-260). No contractor_id filter path. Contractor-submitted entries (contractor_id set, user_id NULL) would not appear for staff user session.",
      "repro": "Contractor portal entries created with contractor_id (once implemented) invisible on current portal history for staff viewer; current viewer sees staff user's own entries only.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-026",
      "severity": "P2",
      "type": "diverges",
      "summary": "Invite permission spec says payouts:write but staff contractor routes use payouts:manage",
      "spec_ref": "Staff API — POST portal-invite Requires payouts:write",
      "code_ref": "apps/zync-api/src/routes/contractors/router.ts:87",
      "evidence": "contractorRoutes.post('/') uses requirePermission('payouts:manage') (line 87). Spec portal-invite requires payouts:write. contractor-payouts audit documents payouts:write not seeded (docs/plans/audit/contractor-payouts.json). Portal invite routes not yet present to verify but dependency permission key already diverges in contractor API surface.",
      "repro": "grep payouts:write in packages/db seed — not in PERMISSION_KEYS; portal invite cannot match spec permission as seeded.",
      "fix_direction": "spec_review"
    },
    {
      "id": "contractor-portal-027",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Portal UI strings hardcoded English — no i18n keys for portal labels",
      "spec_ref": "Plan Task 10 — EN + HE translation keys for portal labels, CSV headers, RTL",
      "code_ref": "apps/zync-app/src/routes/contractor-portal/index.tsx:384",
      "evidence": "grep contractor.portal|contractorPortal in packages/**/translations returns zero matches. Portal pages use literal strings ('Contractor Portal', 'Log Time', 'Hours This Week', etc.). Dates formatted with he-IL locale hardcoded (line 94) without tenant locale dir=rtl wiring.",
      "repro": "Switch tenant locale to Hebrew → portal chrome remains English; no dir=rtl on portal layout.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-portal-028",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Status registry marks contractor-portal COMPLETED despite core auth/API/UI gaps",
      "spec_ref": "Plan Wave 8 tasks 1-10 acceptance criteria",
      "code_ref": "docs/plans/status/contractor-portal.json:4",
      "evidence": "status file shows status:'COMPLETED', completed_at:'2026-06-03T00:00:00Z' while contractor_portal_sessions, contractor JWT, all contractor-portal API routes, magic-link flow, and staff invite UI are absent.",
      "repro": "Read docs/plans/status/contractor-portal.json vs grep contractor-portal implementation evidence above.",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": {
    "P0": 8,
    "P1": 12,
    "P2": 6,
    "P3": 2,
    "total": 28
  }
}
