# Contractor Time Portal

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 87  
**Tier:** All tiers  
**Depends on:** `contractor-payouts`, `time-management`, `projects-module`, `foundation-auth-rbac`  
**Referenced by:** `contractor-payouts`

---

## Overview

Dedicated portal for external contractors to log their own time entries against assigned projects. Spec 21 (`contractor-payouts`) defines the contractor data model and payout flow but all time entry is currently staff-only. This spec adds: contractor authentication via magic link, a minimal portal at `/contractor-portal`, and the time logging UI contractors use to submit hours for review.

---

## Data Model

```sql
-- Schema delta on time_entries:
-- This spec does NOT add an approval column. The canonical approval column
-- time_entries.approval_status (TEXT DEFAULT 'auto_approved',
-- CHECK IN 'auto_approved'|'pending'|'approved'|'rejected'|'locked') is a base column owned by
-- time-management (wave 5, in this spec's deps). Contractor-portal submissions SET it per the
-- tenant's contractor_require_time_approval flag (also owned by time-management, edited via spec 148;
-- see Time Entry Submission).
-- time_entries.source (owned by time-management) includes 'contractor_portal':
-- 'manual' | 'magic_link' | 'auto' | 'contractor_portal'

CREATE TABLE contractor_portal_sessions (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  contractor_id UUID NOT NULL REFERENCES contractors(id) ON DELETE CASCADE,
  token_hash    TEXT NOT NULL,             -- SHA-256 hash of magic link token
  expires_at    TIMESTAMPTZ NOT NULL,
  used_at       TIMESTAMPTZ,              -- NULL = not yet used; set on first use
  created_at    TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_cps_token ON contractor_portal_sessions(token_hash);
```

Contractors do not have rows in the `users` table. Auth is magic-link only, issued by tenant staff from the contractor detail page.

---

## Contractor JWT

On magic link redemption, a contractor session JWT is issued:

```json
{
  "sub": "contractor_uuid",
  "tenantId": "tenant_uuid",
  "role": "contractor",
  "iat": 1234567890,
  "exp": 1234567890
}
```

Session duration: 30 days (renewable on use). All portal API routes require this JWT via `contractorAuthMiddleware`.

---

## Portal Pages

Portal lives at `/contractor-portal/` — served by `zync-app` under a dedicated layout (no sidebar, minimal nav, tenant logo + name).

### Dashboard

```
┌──────────────────────────────────────────────────────────────┐
│  Zync [Tenant Name]          Hello, Dana Contractor          │
│                                                              │
│  ── This week ─────────────────────────────────────────────  │
│  Hours logged:  12.5h                                        │
│  Pending review: 3 entries                                   │
│                                                              │
│  ── Recent activity ───────────────────────────────────────  │
│  Today         Website redesign — Dev     2.5h  Pending      │
│  Yesterday     Website redesign — Dev     4.0h  Approved     │
│  Mon 26        API integration — Backend  3.0h  Approved     │
│                                                              │
│  [Log new time]                                              │
└──────────────────────────────────────────────────────────────┘
```

### Log Time Form

```
┌──────────────────────────────────────────────────────────────┐
│  Log time                                                    │
│                                                              │
│  Project   [Website redesign ▾]  (only assigned projects)   │
│  Task      [Development ▾]                                   │
│  Date      [2026-05-31]                                      │
│  Duration  [2] h [30] min                                    │
│  Notes     [Implemented auth flow_____________________]      │
│                                                              │
│  [Cancel]                              [Submit hours]        │
└──────────────────────────────────────────────────────────────┘
```

Projects shown are those with an active `contractor_assignments` row for this contractor. Tasks are from `tasks` on those projects.

### Time Log History

```
┌──────────────────────────────────────────────────────────────┐
│  My hours            [This month ▾]   [Export CSV]           │
│                                                              │
│  Date          Project               Hours  Status           │
│  ─────────────────────────────────────────────────────────── │
│  2026-05-31    Website redesign       2.5h   Pending         │
│  2026-05-30    Website redesign       4.0h   Approved        │
│  2026-05-29    API integration        3.0h   Approved        │
│                                                              │
│  Total:  9.5h  |  Approved: 7.0h  |  Pending: 2.5h          │
└──────────────────────────────────────────────────────────────┘
```

Contractors can **edit or delete** their own entries while `approval_status` is `pending` (not yet included in a payout bill). Once in a payout bill, entries are `locked`.

### Payout Bills

Read-only view of payout bills:

```
┌──────────────────────────────────────────────────────────────┐
│  Payout history                                              │
│                                                              │
│  May 2026    ₪8,750    Paid 2026-05-20                       │
│  Apr 2026    ₪7,200    Paid 2026-04-18                       │
│  Mar 2026    ₪9,100    Pending                               │
└──────────────────────────────────────────────────────────────┘
```

---

## Staff: Send Portal Invite

From contractor detail page `/contractors/:id` (owned by `contractor-management-ui`):

```
┌──────────────────────────────────────────────────────────────┐
│  Dana Contractor                              [Edit]  [···]   │
│  Portal access: Not enabled                                  │
│                                                              │
│  [Send portal invite]                                        │
└──────────────────────────────────────────────────────────────┘
```

"Send portal invite" → `POST /api/contractors/:id/portal-invite` → generates a magic link token (24-hour expiry), stores hashed in `contractor_portal_sessions`, sends email with link.

After contractor uses link, portal access shows: "Active — last seen 2026-05-31".

---

## Time Entry Submission

Contractor-submitted time entries are created in `time_entries` with:
- `contractor_id` = JWT contractor id
- `user_id` = NULL (spec 13 CHECK constraint allows this)
- `source = 'contractor_portal'` (new source value)
- `approval_status` (base column owned by time-management) is set from the tenant's `contractor_require_time_approval` flag (`tenant_settings`, owned by time-management and edited via `/settings/contractors` spec 148, default `true`): when the flag is `true`, new entries are created `approval_status = 'pending'` (staff must approve before inclusion in a payout bill); when `false`, they are created `approval_status = 'auto_approved'` (matching the column default) and flow straight into payout-bill generation without manual review.

Approval: when approval is required, staff sees pending contractor entries in `/payouts` and approves/rejects per entry. Approved entries (whether auto- or manually approved) can be included in payout bill generation (spec 21 flow unchanged).

---

## API (contractor-scoped routes)

Routes under `/contractor-portal/api/` — all require contractor JWT.

```
GET /contractor-portal/api/me
    → contractor profile, assigned projects

GET /contractor-portal/api/time
    → own time entries (query: month?, approval_status?)

POST /contractor-portal/api/time
    → create time entry
      body: { project_id, task_id?, date, duration_min, notes? }

PATCH /contractor-portal/api/time/:id
      → edit own entry (only if approval_status='pending')

DELETE /contractor-portal/api/time/:id
      → delete own entry (only if approval_status='pending')

GET /contractor-portal/api/bills
    → own payout bills (read-only)
```

---

## Staff API Extension

```
POST /api/contractors/:id/portal-invite
     → send magic link email
       Requires: payouts:write

GET /api/contractors/:id/portal-invite/status
     → { hasPortalAccess, lastSeen }
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Magic link only | Not password auth | Contractors are external; no password management overhead; 30-day session avoids constant re-auth |
| No `users` table row | Separate `contractors` table | Contractors are not employees; mixing them with `users` complicates RBAC and billing |
| `source = 'contractor_portal'` | Not a new table | Time entries are the same entity; source differentiates who entered them without schema duplication |
| Staff approval before payout inclusion | Not auto-approve | Staff must verify contractor-entered hours before paying; spec 21 payout bill generation requires explicit inclusion |
