# Proposal Expiry & Deadline — Implementation Plan

**Spec:** docs/specs/2026-05-31-proposal-expiry-deadline.md  ·  **Slug:** proposal-expiry-deadline  ·  **Wave:** 12
**Depends on:** foundation-auth-rbac, marketing-catalogs-campaigns, public-proposal-view, system-communications-notifications

## Goal
Spec 23 (`marketing-catalogs-campaigns`) already defines `proposals.expires_at TIMESTAMPTZ` and the `'EXPIRED'` status, and spec 53 (`public-proposal-view`) renders an expired banner. This spec adds the behaviour around those columns: a compose-time expiry picker, a tenant-level default-valid-days setting, two daily crons (auto-expiry + 3-day reminder), an urgency countdown on the public proposal page, an expiry column/filter and an "extend/reactivate" action on the `/proposals` list, plus staff notifications and a lead-stage-exit hand-off when a lead's only live proposal expires. No new tables are created.

## Architecture
- **DB delta (one column):** `tenant_settings.proposal_default_valid_days INTEGER` (nullable; NULL = no default expiry). This is per-tenant module config, so it lives on the typed `tenant_settings` table (base owned by `foundation-auth-rbac`), matching the spec's `ALTER TABLE tenant_settings …`. Free-form business-profile prefs live separately in `tenants.settings` JSONB.
- **Consumes upstream tables (all already defined):** `proposals` (cols `id, tenant_id, lead_id, customer_id, name, content, status, public_token, expires_at, sent_at, first_viewed_at, last_viewed_at, accepted_by_name, customer_email, created_by` — spec 23 + `locale` from spec 53), `leads` (`stage`, `customer_id`), `lead_activities` (`type, content, metadata`), `notifications` (`type, title_key, body_key, params, entity_type, entity_id, user_id`), `tenant_memberships` (resolve OWNER/ADMIN recipients), `tenants`.
- **Consumes upstream exports:** `createDb`/`Db` and the `tenantQuery`/`systemQuery` helpers (`@zync/db`), `requirePermission` + `authMiddleware` (`@zync/auth`), `sendEmail` (`@zync/notifications`), `Badge`/`DataTable`/`Select`/`Input`/`Button`/`Form`/`FormField`/`Switch`/`Radio`/`Toast` (`@zync/ui`), `RoleId` (`@zync/types`).
- **createNotification:** the in-app notification rows are written directly into the `notifications` table via the tenant-scoped Drizzle helper (this spec inserts rows; the `system-communications-notifications` delivery layer reads + pushes them). Notification `type` values introduced: `proposal_expired`, `proposal_expiring` (snake_case — matches the dedup predicate `type = 'proposal_expiring'` in the spec's reminder query and the existing `notifications.type` convention).
- **Data flow (auto-expiry):** cron `proposal-expiry` (06:00 UTC) → `UPDATE proposals … SET status='EXPIRED' … RETURNING id, tenant_id, name, customer_id, lead_id` → per row: (a) insert `proposal_expired` notifications for every OWNER/ADMIN member of the tenant; (b) if `lead_id` set, run the `proposal.expired` lead-stage-exit rule (spec 146).
- **Data flow (reminder):** cron `proposal-expiry-reminder` (08:00 UTC) → `SELECT … WHERE expires_at BETWEEN now() AND now()+3 days AND status IN ('SENT','VIEWED') AND id NOT IN (recent proposal_expiring notifications)` → insert `proposal_expiring` notifications.
- **Public countdown:** a pure helper `proposalCountdown(expiresAt, now)` in `@zync/ui` returns a variant the `/p/[token]` Astro page + React island render as an amber/red badge or banner (or nothing when > 7 days / no expiry).
- **Permissions:** proposals are governed by `marketing:read` (view) and `marketing:write` (mutate) — the canonical permissions from spec 23 and spec 156. The expiry spec's prose `proposals:write` is an alias for `marketing:write`; this plan uses `marketing:write`. Settings read/write is gated by role: `GET /api/settings/proposals` requires OWNER or ADMIN; `PUT` requires OWNER.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): the two crons (`scheduled` handler branches keyed by cron expression), `PATCH /api/proposals/:id` extension, `POST /api/proposals/:id/extend`, `GET`/`PUT /api/settings/proposals`. Drizzle (`@zync/db`) over Neon Postgres via Hyperdrive. `sendEmail` (`@zync/notifications`) for optional re-send on extend.
- **apps/zync-www** (Astro + React island, `output: 'hybrid'`): `src/pages/p/[token].astro` (already owned by spec 53) gains the countdown badge/banner via the shared helper.
- **apps/zync-app** (Vite + React): `/proposals` list (spec 156) gains the Expires column, "Expiring soon (7 days)" filter chip, and the row/detail "Extend expiry" action; `/proposals/new` + `/proposals/:id/edit` (spec 130 editor) gain the "Valid until" radio group; `/settings/proposals` gains the "Proposal defaults" section.
- **packages/db** (Drizzle schema): add `proposalDefaultValidDays` to the `tenants` table object + migration.
- **packages/ui**: `proposalCountdown` helper + `ProposalCountdownBadge` component.
- **packages/types**: `ProposalExpiryVariant`, `ProposalCountdown`, `ExtendProposalBody`, `ProposalDefaultsBody` types.
- **wrangler.toml (zync-api):** add `[triggers] crons = ["0 6 * * *", "0 8 * * *", …existing…]`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 12.a (schema + types) | 1, 2 | packages/db/src/schema/tenants.ts, migrations, packages/types/src/proposals.ts | Task 1 ‖ Task 2 |
| 12.b (shared helper) | 3 | packages/ui/src/proposal-countdown.* | after 2 |
| 12.c (settings API) | 4 | apps/zync-api/src/routes/settings-proposals.ts | after 1 |
| 12.d (compose+settings UI) | 5, 6 | apps/zync-app proposal editor, /settings/proposals | after 3,4 (5 ‖ 6) |
| 12.e (PATCH + extend API) | 7, 8 | apps/zync-api/src/routes/proposals.ts | after 1 (7 ‖ 8) |
| 12.f (crons) | 9, 10 | apps/zync-api/src/cron/*, wrangler.toml | after 1 (9 ‖ 10) |
| 12.g (public countdown) | 11 | apps/zync-www p/[token].astro + island | after 3 |
| 12.h (list column/filter/extend) | 12 | apps/zync-app /proposals list | after 3,8 |
| 12.i (verification) | 13 | — | last |

## Tasks

### Task 1: Add `proposal_default_valid_days` to `tenant_settings` + migration
**Blocks:** 4, 5, 6, 7, 8, 9, 10  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/tenant-settings.ts`
- Create: `packages/db/migrations/<nnnn>_proposal_default_valid_days.sql`
**Steps:**
- [ ] Add `proposalDefaultValidDays: integer('proposal_default_valid_days')` (nullable) to the existing `tenant_settings` Drizzle table object — the base table is owned by `foundation-auth-rbac`; do not create a new table.
- [ ] Write the forward migration DDL (below). NULL means "no default expiry".
**Schema / Interfaces:**
```sql
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS proposal_default_valid_days INTEGER;
-- NULL = no default expiry. When set, a proposal sent (status -> SENT) with a NULL
-- expires_at gets expires_at = sent_at + proposal_default_valid_days * interval '1 day'.
```
```ts
// packages/db/src/schema/tenant-settings.ts — add to the existing tenant_settings table object
proposalDefaultValidDays: integer('proposal_default_valid_days'), // nullable
```
**Acceptance:**
- [ ] `drizzle-kit` generates no diff after migration applied; `tenant_settings.proposal_default_valid_days` exists and is nullable INTEGER.
- [ ] `tenant_settings` is NOT created here (base owned by `foundation-auth-rbac`); only `ALTER ... ADD COLUMN IF NOT EXISTS`.

### Task 2: Proposal-expiry shared types
**Blocks:** 3, 5, 6, 7, 8, 11, 12  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/proposals.ts` (or create if absent)
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Add the variant union, countdown result, and request-body types below.
- [ ] Export them from the package barrel so API + UI + Astro share one definition.
**Schema / Interfaces:**
```ts
// Urgency variant for the public countdown (and list expiry cell).
export type ProposalExpiryVariant =
  | 'none'        // expires_at null, or > 7 days away, or status not SENT/VIEWED
  | 'amber'       // 3–7 days remaining
  | 'red'         // < 3 days remaining
  | 'today'       // expires today
  | 'expired';    // expires_at < now()

export interface ProposalCountdown {
  variant: ProposalExpiryVariant;
  daysRemaining: number | null; // null when variant === 'none' | 'expired'
  expiresAt: string | null;     // ISO-8601, echoes input
}

export interface ExtendProposalBody {
  expires_at?: string;          // ISO date; defaults server-side to today + tenant default
  resend?: boolean;             // re-send the proposal email
}

export interface ProposalDefaultsBody {
  proposal_default_valid_days?: number | null; // null clears the default
}

export interface ProposalDefaultsResponse {
  proposal_default_valid_days: number | null;
}
```
**Acceptance:**
- [ ] `import { ProposalCountdown, ProposalExpiryVariant, ExtendProposalBody, ProposalDefaultsBody } from '@zync/types'` resolves with no type errors.

### Task 3: `proposalCountdown` helper + `ProposalCountdownBadge` component
**Blocks:** 5, 11, 12  ·  **Blocked by:** 2
**Files:**
- Create: `packages/ui/src/proposal-countdown.ts`
- Create: `packages/ui/src/components/ProposalCountdownBadge.tsx`
- Modify: `packages/ui/src/index.ts` (re-export both)
**Steps:**
- [ ] Implement `proposalCountdown(expiresAt, now, status)` as a pure function returning `ProposalCountdown`. Day arithmetic uses calendar-day difference in UTC (floor of ms diff / 86_400_000), then maps to the variant thresholds verbatim from the spec.
- [ ] `none` when `expiresAt` is null, status is not in `('SENT','VIEWED')`, or `daysRemaining > 7`. `amber` for 3–7 inclusive. `red` for 1–2. `today` when same UTC calendar day (`daysRemaining === 0`). `expired` when `expiresAt < now`.
- [ ] `ProposalCountdownBadge` renders nothing for `none`; for `amber`/`red` an inline `Badge` "Expires in N days" (amber/red token); for `today` a `red` banner "Expires today"; for `expired` a muted "Expired on {date}". Honour `prefers-reduced-motion` (no animated hourglass spin; the ⏳ glyph is static text). Use logical CSS / RTL-aware spacing (`@zync/ui` conventions) — no hardcoded colors (use `--warning` / `--danger` tokens) and no hardcoded spacing.
- [ ] Add `aria-label` on the badge ("Proposal expires in N days") and `role="status"` on the "Expires today" banner so screen readers announce urgency.
**Schema / Interfaces:**
```ts
export function proposalCountdown(
  expiresAt: string | null,
  now: Date,
  status: string,
): ProposalCountdown;

export interface ProposalCountdownBadgeProps {
  expiresAt: string | null;
  status: string;
  locale?: 'he' | 'en'; // formats the {date} via formatDate from @zync/ui
}
export function ProposalCountdownBadge(props: ProposalCountdownBadgeProps): JSX.Element | null;
```
**Acceptance:**
- [ ] Unit assertions hold: 10 days → `none`; 5 days → `amber`; 2 days → `red`; same-day → `today`; yesterday → `expired`; null → `none`; status `'DRAFT'` with future expiry → `none`.
- [ ] Badge returns `null` (renders nothing) for `none`. No hardcoded hex colors; only `--warning`/`--danger` tokens.

### Task 4: `GET` / `PUT /api/settings/proposals`
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/settings-proposals.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount router)
**Steps:**
- [ ] `GET /api/settings/proposals`: require an authenticated session whose role is OWNER or ADMIN (`requirePermission` against role, or an explicit role check using `RoleId`); read `proposal_default_valid_days` from the tenant's `tenant_settings` row via `tenantQuery`; return `{ proposal_default_valid_days }`.
- [ ] `PUT /api/settings/proposals`: require role OWNER only; validate body with Zod (`proposal_default_valid_days` optional `number` ≥ 1 and ≤ 3650, or `null`); upsert the tenant's `tenant_settings` row; return the updated value.
- [ ] Reject non-OWNER on PUT with 403; reject non-OWNER/ADMIN on GET with 403.
- [ ] Use Zod validation in the route (required-zod-validation-in-routes); never call raw Drizzle from the route body — go through `tenantQuery`.
**Schema / Interfaces:**
```ts
// Zod
const proposalDefaultsSchema = z.object({
  proposal_default_valid_days: z.number().int().min(1).max(3650).nullable().optional(),
});
// GET  /api/settings/proposals -> ProposalDefaultsResponse   (OWNER|ADMIN)
// PUT  /api/settings/proposals  body: ProposalDefaultsBody -> ProposalDefaultsResponse (OWNER)
```
**Acceptance:**
- [ ] OWNER can read+write; ADMIN can read but PUT returns 403; member role gets 403 on both.
- [ ] `PUT { proposal_default_valid_days: null }` clears the default; `GET` then returns `null`.

### Task 5: Compose UI — "Valid until" radio in proposal editor
**Blocks:** —  ·  **Blocked by:** 3, 4
**Files:**
- Modify: `apps/zync-app/src/features/proposals/ProposalEditor.tsx` (spec 130 editor)
- Modify: `apps/zync-app/src/features/proposals/api.ts` (PATCH payload includes `expires_at`)
**Steps:**
- [ ] Add a "Proposal settings" block with a `Radio` group: "No expiry" (sets `expires_at` to `null`) and "Expires on" + a date input (sets `expires_at` to the chosen ISO date at 23:59:59Z of that day).
- [ ] Default selection: "No expiry". If the editing tenant has `proposal_default_valid_days` set and the proposal has no `expires_at`, prefill the "Expires on" date with today + default days but leave the radio on "No expiry" unless the user opts in (spec: default is no expiry; the tenant default applies at send time, not in the picker).
- [ ] Show the info note verbatim: "Expired proposals lock acceptance and show an expiry banner to the recipient." with an info icon (aria-hidden glyph + visible text).
- [ ] On save, include `expires_at?: string | null` in the `PATCH /api/proposals/:id` body.
- [ ] RTL-aware layout; date input localized via the proposal locale.
**Acceptance:**
- [ ] Selecting "No expiry" then saving sends `expires_at: null`; selecting a date sends an ISO timestamp.
- [ ] Info note text renders exactly as specified and is reachable by screen readers.

### Task 6: Settings UI — "Proposal defaults" section at `/settings/proposals`
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/ProposalDefaultsSection.tsx`
- Modify: `apps/zync-app/src/features/settings/SettingsProposalsPage.tsx` (route `/settings/proposals`)
**Steps:**
- [ ] Render a `Radio` group: "No default expiry" (clears value → `null`) and "[N] days after sending" with a numeric `Input` bound to `proposal_default_valid_days`.
- [ ] Load current value from `GET /api/settings/proposals`; save via `PUT`. Show success `Toast` on save.
- [ ] Gate the section: visible to OWNER/ADMIN; the Save action enabled only for OWNER (ADMIN sees read-only). Disabled inputs carry `aria-disabled`.
- [ ] Validate N ≥ 1 inline before submit.
**Acceptance:**
- [ ] Setting "30 days" then reloading shows 30; choosing "No default expiry" persists `null`.
- [ ] ADMIN sees current value read-only; member cannot reach the page (route guarded by `marketing`/role check consistent with settings-module).

### Task 7: Extend `PATCH /api/proposals/:id` with `expires_at`
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/proposals.ts`
**Steps:**
- [ ] Extend the existing `PATCH /api/proposals/:id` Zod schema with `expires_at: z.string().datetime().nullable().optional()`.
- [ ] Require `marketing:write` (`requirePermission('marketing:write')`).
- [ ] Persist `expires_at` (null clears it) via `tenantQuery`; touch `updated_at = now()`.
- [ ] Do not change status here (status transitions are owned by send/accept/expire flows).
**Schema / Interfaces:**
```ts
// PATCH /api/proposals/:id  (marketing:write)
// body adds: { expires_at?: string | null }   // ISO-8601; null clears expiry
```
**Acceptance:**
- [ ] `PATCH { expires_at: "2026-06-30T23:59:59Z" }` sets the column; `{ expires_at: null }` clears it.
- [ ] Caller without `marketing:write` gets 403.

### Task 8: `POST /api/proposals/:id/extend` (reactivate an EXPIRED proposal)
**Blocks:** 12  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/proposals.ts`
- Modify: `apps/zync-api/src/services/proposal-activity.ts` (append activity-feed entry)
**Steps:**
- [ ] Require `marketing:write`. Load the proposal via `tenantQuery`; if `status !== 'EXPIRED'` return 409 (extend only applies to expired proposals).
- [ ] Compute new `expires_at`: use body `expires_at` if provided, else today + `tenant_settings.proposal_default_valid_days` (if the tenant default is NULL and no body value, return 422 — must supply an explicit date).
- [ ] Update: `status = 'SENT'`, `expires_at = <new>`, `updated_at = now()`. Preserve `public_token`, `first_viewed_at`/`last_viewed_at`/`view_count`, `accepted_by_name`, `customer_email`, `customer_id`, `lead_id` (no token re-mint — this is the key difference from Duplicate in spec 156).
- [ ] If `resend === true`, send the proposal email via `sendEmail` to `customer_email` with the existing `/p/{public_token}` link.
- [ ] Log the reactivation to the proposal activity feed ("Proposal reactivated — new expiry {date}").
- [ ] If `lead_id` is set and the linked lead is in `QUALIFIED`, advance it back to `PROPOSAL` (mirror of spec 146 stage auto-advance on SENT) and write a `lead_activities` row (`type='stage_changed'`).
**Schema / Interfaces:**
```ts
// POST /api/proposals/:id/extend  (marketing:write)
// body: ExtendProposalBody = { expires_at?: string; resend?: boolean }
// 409 if status !== 'EXPIRED'; 422 if no expires_at and tenant default is null.
// On success: status -> 'SENT', expires_at set, public_token unchanged.
```
**Acceptance:**
- [ ] Extending an EXPIRED proposal returns it as SENT with the same `public_token` and a future `expires_at`.
- [ ] `resend: true` triggers exactly one `sendEmail` to the recipient; `resend` omitted sends none.
- [ ] Calling extend on a SENT/DRAFT proposal returns 409.
- [ ] Activity feed gains a reactivation entry.

### Task 9: Cron `proposal-expiry` (daily 06:00 UTC) — auto-expiry + notifications + lead state-exit
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/cron/proposal-expiry.ts`
- Modify: `apps/zync-api/src/index.ts` (route `scheduled` by cron expression)
- Modify: `apps/zync-api/wrangler.toml` (`[triggers] crons`)
**Steps:**
- [ ] In the Worker `scheduled(event)` handler, dispatch on `event.cron === '0 6 * * *'` to `runProposalExpiry(env)`.
- [ ] Run the expiry `UPDATE … RETURNING` (below) inside `systemQuery` (cross-tenant; iterate all tenants in one statement).
- [ ] For each returned row, insert one `notifications` row per OWNER/ADMIN member of `tenant_id` (resolve members via `tenant_memberships` joined to `roles`): `type='proposal_expired'`, `title_key='notifications.proposal_expired.title'`, `body_key='notifications.proposal_expired.body'`, `params={ title: name }`, `entity_type='proposal'`, `entity_id=id`.
- [ ] Lead state-exit (spec 146): if `lead_id` is set, load the lead. If its `stage === 'PROPOSAL'` AND it has no other proposal still in `('SENT','VIEWED')`, set lead `stage='QUALIFIED'` and insert a `lead_activities` row `type='stage_changed'`, `content='Proposal expired — lead returned to Qualified for follow-up.'`, `metadata={ from:'PROPOSAL', to:'QUALIFIED', reason:'proposal_expired' }`. Leads in WON/LOST, or past PROPOSAL, or with another live proposal, are left untouched.
- [ ] Wrap each tenant's notification + lead-stage write in a transaction so a partial failure does not leave a half-applied state.
**Schema / Interfaces:**
```sql
-- Auto-expiry (spec verbatim)
UPDATE proposals
SET status = 'EXPIRED', updated_at = now()
WHERE expires_at IS NOT NULL
  AND expires_at < now()
  AND status IN ('SENT', 'VIEWED')
RETURNING id, tenant_id, name AS title, customer_id, lead_id;

-- "another live proposal" guard for the lead state-exit
SELECT 1 FROM proposals
WHERE lead_id = $1 AND status IN ('SENT','VIEWED') AND id <> $2
LIMIT 1;

-- OWNER/ADMIN recipients for the tenant
SELECT tm.user_id
FROM tenant_memberships tm
JOIN roles r ON r.id = tm.role_id
WHERE tm.tenant_id = $1 AND r.name IN ('OWNER','ADMIN');
```
```ts
// notification insert
{ tenant_id, user_id, type: 'proposal_expired',
  title_key: 'notifications.proposal_expired.title',
  body_key: 'notifications.proposal_expired.body',
  params: { title }, entity_type: 'proposal', entity_id }
```
**Acceptance:**
- [ ] A SENT proposal with `expires_at` in the past becomes EXPIRED; an EXPIRED/ACCEPTED/DRAFT proposal is untouched.
- [ ] Each OWNER/ADMIN of the tenant receives one `proposal_expired` notification per expired proposal.
- [ ] A lead in PROPOSAL whose only live proposal expired is reverted to QUALIFIED with the `stage_changed` activity; a lead with a second live proposal is unchanged.

### Task 10: Cron `proposal-expiry-reminder` (daily 08:00 UTC) — 3-day reminder
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/cron/proposal-expiry-reminder.ts`
- Modify: `apps/zync-api/src/index.ts` (dispatch on cron expression)
**Steps:**
- [ ] Dispatch on `event.cron === '0 8 * * *'` to `runProposalExpiryReminder(env)`.
- [ ] Run the SELECT (below) under `systemQuery`. The `id NOT IN (… proposal_expiring notifications in last 3 days)` predicate dedupes so a proposal is reminded at most once per 3-day window.
- [ ] For each row compute `N = ceil((expires_at - now) / 1 day)` and insert one `proposal_expiring` notification per OWNER/ADMIN member: `params={ title, days: N }`.
**Schema / Interfaces:**
```sql
-- Reminder query (spec verbatim, name -> title alias)
SELECT id, tenant_id, name AS title, customer_id, lead_id, expires_at
FROM proposals
WHERE expires_at BETWEEN now() AND now() + interval '3 days'
  AND status IN ('SENT', 'VIEWED')
  AND id NOT IN (
    SELECT entity_id FROM notifications
    WHERE type = 'proposal_expiring'
      AND entity_id IS NOT NULL
      AND created_at > now() - interval '3 days'
  );
```
```ts
{ tenant_id, user_id, type: 'proposal_expiring',
  title_key: 'notifications.proposal_expiring.title',
  body_key: 'notifications.proposal_expiring.body',
  params: { title, days }, entity_type: 'proposal', entity_id: id }
```
**Acceptance:**
- [ ] A SENT proposal expiring in 2 days produces one `proposal_expiring` notification per OWNER/ADMIN; running the cron again the same day produces none (dedup).
- [ ] Proposals expiring > 3 days out, or already EXPIRED/ACCEPTED, are not reminded.
- [ ] `wrangler.toml` `[triggers] crons` contains both `"0 6 * * *"` and `"0 8 * * *"` alongside existing crons.

### Task 11: Public proposal view countdown (`/p/[token]`)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-www/src/pages/p/[token].astro`
- Modify: `apps/zync-www/src/components/ProposalView.tsx` (React island)
**Steps:**
- [ ] After fetching the proposal (`GET /api/proposals/:token/public`), compute the countdown with `proposalCountdown(proposal.expiresAt, new Date(), proposal.status)`.
- [ ] Render `ProposalCountdownBadge` above the proposal content for SENT/VIEWED proposals: `amber`/`red` badge "Expires in N days (date)", `today` → red banner "Expires today". For `none` (>7 days or no expiry) render nothing.
- [ ] For the expired state, defer to spec 53's existing amber "This proposal expired on {date}" banner with CTAs hidden — the countdown helper returns `expired` and the page suppresses Accept/Decline (already spec-53 behaviour); do not duplicate the banner.
- [ ] Use the proposal locale (`proposal.locale ?? 'he'`) for date formatting and keep the page `dir`/`lang` per spec 53. Honour `prefers-reduced-motion`.
**Acceptance:**
- [ ] Proposal expiring in 4 days shows an amber "Expires in 4 days (2026-06-30)" badge; in 2 days shows a red badge; today shows a red "Expires today" banner; > 7 days shows nothing.
- [ ] Expired proposals show only the spec-53 expiry banner (no double banner) and hide Accept/Decline.

### Task 12: Proposal list — Expires column, "Expiring soon" filter, Extend action (`/proposals`)
**Blocks:** —  ·  **Blocked by:** 3, 8
**Files:**
- Modify: `apps/zync-app/src/features/proposals/ProposalsListPage.tsx`
- Modify: `apps/zync-app/src/features/proposals/columns.tsx`
- Modify: `apps/zync-app/src/features/proposals/api.ts` (extend endpoint client + `expiringSoon` filter param)
**Steps:**
- [ ] Add an "Expires" column rendering `ProposalCountdownBadge` (SENT/VIEWED → ⏳ "N days" or absolute date), "—" for DRAFT/no-expiry, "✗ {date}" for EXPIRED.
- [ ] Add an "Expiring soon (7 days)" filter chip that requests `GET /api/proposals?expiringSoon=true` (server filters SENT/VIEWED with `expires_at` within 7 days). Implement the filter param in the existing list handler (extend `GET /api/proposals` query parsing in `apps/zync-api/src/routes/proposals.ts`).
- [ ] Add an "[Extend expiry]" row action (and detail action) shown only when `status === 'EXPIRED'`: opens a small dialog with a date input prefilled to today + tenant default and a "Re-send email" checkbox, then calls `POST /api/proposals/:id/extend`. On success, refresh the row (status flips to SENT).
- [ ] Ensure column header + filter chip are RTL-aware and keyboard-focusable; the Extend dialog traps focus and is dismissible via Escape (a11y).
**Schema / Interfaces:**
```ts
// GET /api/proposals?expiringSoon=true  (marketing:read)
//   server adds: WHERE status IN ('SENT','VIEWED')
//                AND expires_at IS NOT NULL
//                AND expires_at <= now() + interval '7 days'
//                AND expires_at >= now()
```
**Acceptance:**
- [ ] List shows the Expires cell per status (badge / "—" / "✗ date").
- [ ] "Expiring soon (7 days)" chip narrows to SENT/VIEWED proposals expiring within 7 days.
- [ ] "Extend expiry" appears only on EXPIRED rows; completing the dialog reactivates the proposal (SENT, same token) and optionally re-sends.

### Task 13: Verification
**Blocks:** —  ·  **Blocked by:** 1–12
**Files:**
- Modify: — (runs commands; no source change)
**Steps:**
- [ ] `pnpm -w typecheck` and `pnpm -w lint` pass across `@zync/db`, `@zync/types`, `@zync/ui`, `apps/zync-api`, `apps/zync-app`, `apps/zync-www` (no hardcoded colors/spacing in the new UI per lint rules).
- [ ] Run the expiry cron locally against a seeded EXPIRED-eligible proposal (`wrangler dev --test-scheduled` or the project's cron harness) and confirm status flips, notifications insert, and the lead reverts to QUALIFIED.
- [ ] Run the reminder cron twice and confirm second run inserts zero duplicate `proposal_expiring` notifications.
- [ ] Manually exercise `PATCH /api/proposals/:id` (set/clear `expires_at`), `POST /api/proposals/:id/extend` (409 on non-expired, success on expired), and `GET`/`PUT /api/settings/proposals` (role gating).
- [ ] Load `/p/{token}` for proposals at 10/5/2/0 days and confirm the correct badge/banner variant; confirm `prefers-reduced-motion` removes any animation.
**Acceptance:**
- [ ] All typecheck/lint commands exit 0.
- [ ] Each acceptance criterion in Tasks 1–12 is observed at least once against a running stack.
