# Project Archive & Completion — Implementation Plan

**Spec:** docs/specs/2026-05-31-project-archive-complete.md  ·  **Slug:** project-archive-complete  ·  **Wave:** 7
**Depends on:** foundation-auth-rbac, invoices-core, projects-module, tasks-board-engine, time-management

## Goal
Define the two end-states of a project — `completed` (work done, invoicing/time finalization still allowed) and `archived` (hard read-only lock, fully settled) — and the transitions between them. Completion gathers a summary (open tasks, unbilled time, outstanding invoices), optionally closes open tasks, and notifies the OWNER of unbilled hours. Archiving locks all downstream writes (no new time entries, tasks, or invoices on the project) while existing invoices continue their lifecycle. Unarchive restores to `completed`. The `/projects` list and global search gain status-aware filtering/badging.

## Architecture
This spec adds **no new tables**. It extends the existing `projects` table (from `projects-module`) with two timestamp columns and three new lifecycle endpoints. Its defining work is a **shared write-guard** (`assertProjectWritable`) that upstream modules call before creating project-scoped records:

- **projects** (`projects.status`, `projects.completed_at`, `projects.archived_at`) — owned here as a schema delta.
- **tasks** / **task_statuses** (`tasks.status_id`, `task_statuses.is_terminal`, `task_statuses.position`) — read for open-task counting and "mark all done"; `POST /api/tasks` gains the archive guard.
- **time_entries** (`time_entries.project_id`, `duration_seconds`, `billable`, `invoice_id`) — read for unbilled-hours summary; `POST /api/time/start`, `POST /api/time`, `POST /api/time/:id/stop` gain the archive guard. `time_entries.invoice_id` is owned by spec 77 and already assumed by `invoices-core`; this plan only reads it.
- **invoices** (`invoices.status`, `invoices.total`, `invoices.project_id`) — read for outstanding-amount summary; invoice-create paths gain the archive guard. Outstanding is derived from `status IN ('SENT','APPROVED','TAX_ISSUED','PARTIALLY_PAID')` summed over `total` — **not** from `amount_paid` (that column is owned by spec 80, outside this closure).
- **tenant_memberships** / **roles** — queried to resolve the OWNER user for the unbilled-time notification.
- Upstream exports consumed: `tenantQuery`, `requirePermission`, `requireModuleEnabled`, `buildPaginated`, `createNotification`, `ModuleId`. Audit is written inside each mutation transaction per the locked `require-audit-in-transaction` rule (this satisfies the spec's "Project activity: Marked complete by {user}" line — no separate activity table is created).

Data flow on complete: client opens summary sheet → `GET /completion-summary` → user picks task disposition → `POST /complete` (single transaction: status update + optional bulk task close + audit) → OWNER notification enqueued if unbilled hours on an hourly project. Archive/unarchive are simple guarded status transitions.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): new routes under `apps/zync-api/src/routes/projects/` (lifecycle handlers) plus guard insertion into existing `tasks`, `time`, and `invoices` route handlers.
- **packages/projects** (or the existing projects package): exports `assertProjectWritable`, `getCompletionSummary`, `completeProject`, `archiveProject`, `unarchiveProject`, `ProjectArchivedError`.
- **apps/zync-app** (Vite + React): completion sheet, archive dialog, archived-state banner, `/projects` status filter, search badge; TanStack Query hooks.
- **packages/db** (Drizzle): `projects` schema delta.
- **packages/types**: `CompletionSummary`, `ProjectLifecycleStatus`.
- Bindings: Hyperdrive (Neon Postgres). No new bindings.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema & shared guard | 1, 2 | `packages/db/src/schema/projects.ts`, `packages/projects/src/guard.ts`, `packages/types` | Task 1 then Task 2 (2 depends on 1) |
| B — service layer | 3, 4 | `packages/projects/src/lifecycle.ts` | After A; tasks 3-4 sequential within file |
| C — API routes | 5, 6 | `apps/zync-api/src/routes/projects/lifecycle.ts` | After B |
| D — upstream guard wiring | 7, 8, 9 | tasks/time/invoices route handlers | After Task 2; parallel with each other and with C |
| E — UI | 10, 11, 12, 13 | `apps/zync-app/...` | After C; 10-13 parallel |

## Tasks

### Task 1: Projects schema delta — completion & archive timestamps
**Blocks:** 2, 3, 4  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/projects.ts`
- Create: `packages/db/migrations/<ts>_project_archive_complete.sql`
**Steps:**
- [ ] Add `completedAt` and `archivedAt` columns to the Drizzle `projects` table definition (`timestamp(..., { withTimezone: true })`, nullable).
- [ ] Write the idempotent migration SQL below.
- [ ] Confirm `projects.status` already permits `'completed'` and `'archived'` (it does, per `projects-module`: `'active' | 'on_hold' | 'completed' | 'archived'`); do **not** add a new CHECK that narrows it.
**Schema / Interfaces:**
```sql
ALTER TABLE projects
  ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ,
  ADD COLUMN IF NOT EXISTS archived_at  TIMESTAMPTZ;

-- Index to keep default list (active + completed) and "show archived" filters fast:
CREATE INDEX IF NOT EXISTS projects_tenant_status_idx
  ON projects (tenant_id, status);
```
Drizzle column additions:
```ts
completedAt: timestamp('completed_at', { withTimezone: true }),
archivedAt:  timestamp('archived_at',  { withTimezone: true }),
```
**Acceptance:**
- [ ] Migration runs cleanly twice (idempotent) against Neon Postgres.
- [ ] `completed_at` / `archived_at` are `TIMESTAMPTZ` and nullable; no enum/CHECK change to `status`.

### Task 2: Shared write-guard `assertProjectWritable`
**Blocks:** 3, 4, 7, 8, 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/projects/src/guard.ts`
- Modify: `packages/projects/src/index.ts` (export)
- Modify: `packages/types/src/index.ts` (error type)
**Steps:**
- [ ] Implement `assertProjectWritable(db, tenantId, projectId)` that loads the project via `tenantQuery` and throws `ProjectArchivedError` (HTTP 409) when `status = 'archived'`.
- [ ] `completed` projects remain writable (finalization window) — the guard only blocks `archived`.
- [ ] If the project row is missing, throw a `not found` error (404) — do not silently allow.
- [ ] Export a typed `ProjectArchivedError` so upstream route error handlers map it to HTTP 409 with a stable code.
**Schema / Interfaces:**
```ts
export class ProjectArchivedError extends Error {
  readonly code = 'project_archived';
  readonly status = 409;
  constructor(public projectId: string) {
    super('Project is archived and locked for new records');
  }
}

/** Throws ProjectArchivedError(409) if project is archived; NotFoundError(404) if missing.
 *  `completed` and `active` projects pass. Call before creating any project-scoped record. */
export async function assertProjectWritable(
  db: Db, tenantId: string, projectId: string
): Promise<void>;
```
**Acceptance:**
- [ ] Calling on an `archived` project throws `ProjectArchivedError` (409).
- [ ] Calling on `active` or `completed` projects returns without throwing.
- [ ] Calling on a non-existent / cross-tenant project id throws 404.

### Task 3: Completion summary service `getCompletionSummary`
**Blocks:** 5  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/projects/src/lifecycle.ts`
- Modify: `packages/projects/src/index.ts` (export)
- Modify: `packages/types/src/index.ts` (`CompletionSummary`)
**Steps:**
- [ ] Implement `getCompletionSummary(db, tenantId, projectId)` returning the four figures the spec mandates.
- [ ] **open_tasks**: count `tasks` for the project whose `status_id` references a `task_statuses` row with `is_terminal = false`.
- [ ] **unbilled_hours**: `SUM(duration_seconds)/3600` over `time_entries` where `project_id = :id AND billable = true AND invoice_id IS NULL AND stopped_at IS NOT NULL`, rounded to 2 decimals.
- [ ] **unbilled_amount**: `unbilled_hours × project hourly rate`. Resolve rate from `billing_config->>'rate_per_hour'` when `billing_type = 'hourly'`; for non-hourly billing types `unbilled_amount = 0` (no per-hour rate to apply).
- [ ] **outstanding_invoice_amount**: `SUM(total)` over `invoices` where `project_id = :id AND status IN ('SENT','APPROVED','TAX_ISSUED','PARTIALLY_PAID')`. Do **not** subtract `amount_paid` — that column is owned by spec 80 (`partial-payment-recording`), outside this dependency closure; status-based outstanding matches the spec mockup ("₪4,200 on INV-0038 (SENT)").
- [ ] All queries scoped through `tenantQuery` (no raw drizzle from service per `no-raw-drizzle-from-routes` spirit; service file is the data-access layer).
**Schema / Interfaces:**
```ts
export interface CompletionSummary {
  open_tasks: number;
  unbilled_hours: number;            // 2-decimal hours
  unbilled_amount: number;           // currency units; 0 for non-hourly billing
  outstanding_invoice_amount: number;// SUM(total) of non-terminal invoices
}
export async function getCompletionSummary(
  db: Db, tenantId: string, projectId: string
): Promise<CompletionSummary>;
```
**Acceptance:**
- [ ] Open-task count excludes terminal statuses (`is_terminal = true`).
- [ ] Unbilled excludes non-billable and already-invoiced (`invoice_id` set) entries and running timers (`stopped_at IS NULL`).
- [ ] Outstanding sums only `SENT/APPROVED/TAX_ISSUED/PARTIALLY_PAID`; `DRAFT/PAID/VOID/REJECTED/WRITTEN_OFF/BAD_DEBT` excluded.

### Task 4: Lifecycle transition services `completeProject` / `archiveProject` / `unarchiveProject`
**Blocks:** 5  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `packages/projects/src/lifecycle.ts`
- Modify: `packages/projects/src/index.ts` (exports)
**Steps:**
- [ ] **completeProject(db, ctx, projectId, { closeOpenTasks })** in one transaction:
  - Set `status = 'completed'`, `completed_at = now()` (only valid from `active`/`on_hold`; reject if already `archived`).
  - If `closeOpenTasks`: bulk-update every open task (status with `is_terminal = false`) to the project's terminal status — pick the `task_statuses` row scoped to the project (or tenant-global `project_id IS NULL`) with `is_terminal = true` and the lowest `position`. If **no** terminal status exists, skip task closure (leave tasks open) rather than error.
  - Write one audit entry inside the transaction (`require-audit-in-transaction`): action `project.completed`, actor = `ctx.userId`, satisfies the spec's "Marked complete by {user}" activity — no separate `project_activity` table.
  - After commit, if `billing_type = 'hourly'` AND `unbilled_hours > 0`: resolve the OWNER user (Task: query `tenant_memberships` joined to `roles` for the owner role within the tenant) and call `createNotification` with body "₪{unbilled_amount} unbilled on completed project {name}". Fire-and-await; do not roll back completion if notification fails (log instead).
- [ ] **archiveProject(db, ctx, projectId)** in one transaction:
  - Precondition: project must be `completed`; reject (409) otherwise — archive is only offered on completed projects.
  - Set `status = 'archived'`, `archived_at = now()`.
  - Audit entry `project.archived`.
- [ ] **unarchiveProject(db, ctx, projectId)** in one transaction:
  - Precondition: project must be `archived`.
  - Set `status = 'completed'`, `archived_at = NULL` (keep `completed_at`).
  - Audit entry `project.unarchived`.
**Schema / Interfaces:**
```ts
export async function completeProject(
  db: Db, ctx: { tenantId: string; userId: string },
  projectId: string, opts: { closeOpenTasks?: boolean }
): Promise<{ status: 'completed'; completed_at: string }>;

export async function archiveProject(
  db: Db, ctx: { tenantId: string; userId: string }, projectId: string
): Promise<{ status: 'archived'; archived_at: string }>;

export async function unarchiveProject(
  db: Db, ctx: { tenantId: string; userId: string }, projectId: string
): Promise<{ status: 'completed' }>;
```
**Acceptance:**
- [ ] Completion sets timestamp and, when requested, moves all non-terminal tasks to the lowest-position terminal status; zero-terminal-status case leaves tasks open without error.
- [ ] Archive is rejected (409) unless current status is `completed`.
- [ ] Unarchive clears `archived_at`, preserves `completed_at`, returns status `completed`.
- [ ] Each transition writes exactly one audit row in the same transaction.
- [ ] OWNER notification only fires for hourly projects with `unbilled_hours > 0`.

### Task 5: Lifecycle API routes
**Blocks:** 10, 11, 12  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/projects/lifecycle.ts`
- Modify: `apps/zync-api/src/routes/projects/index.ts` (mount)
**Steps:**
- [ ] Register routes on the projects router, each behind `authMiddleware`, `requireModuleEnabled('projects')`, and the per-endpoint permission below.
- [ ] `GET /api/projects/:id/completion-summary` → `requirePermission('projects:read')` → returns `CompletionSummary`.
- [ ] `POST /api/projects/:id/complete` → `requirePermission('projects:write')` → Zod body `{ close_open_tasks?: boolean }` (per `require-zod-validation-in-routes`) → `completeProject(..., { closeOpenTasks: body.close_open_tasks ?? false })`.
- [ ] `POST /api/projects/:id/archive` → `requirePermission('projects:write')` → `archiveProject(...)`.
- [ ] `POST /api/projects/:id/unarchive` → `requirePermission('projects:write')` → `unarchiveProject(...)`.
- [ ] **Permission divergence note (reconcile):** this spec specifies `projects:write` for all four endpoints; `projects-module`'s permission table lists "Archive / delete → `projects:delete`". This plan follows the more specific per-endpoint authority of *this* spec (`projects:write`). This is a deliberate reconciliation, not an oversight — keep `projects:write` and do not gate archive on `projects:delete`.
- [ ] Map `ProjectArchivedError`→409, not-found→404, precondition failures→409 with a JSON error code.
**Schema / Interfaces:**
```
GET  /api/projects/:id/completion-summary  → 200 CompletionSummary           (projects:read)
POST /api/projects/:id/complete            body { close_open_tasks?: boolean } (projects:write)
POST /api/projects/:id/archive             → 200 { status:'archived', archived_at } (projects:write)
POST /api/projects/:id/unarchive           → 200 { status:'completed' }        (projects:write)
```
**Acceptance:**
- [ ] All four endpoints enforce the stated permission and `projects` module-enabled guard.
- [ ] `complete` validates its body with Zod; bad body → 400.
- [ ] Archiving a non-`completed` project returns 409.

### Task 6: Reconcile legacy `DELETE /api/projects/:id` ("archive (soft)")
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/src/routes/projects/index.ts` (existing DELETE handler)
**Steps:**
- [ ] `projects-module` defines `DELETE /api/projects/:id → archive (soft)`. To avoid two divergent archive paths, route the legacy DELETE so it cannot bypass this spec's rules. Implement: DELETE on an `active` project first marks it `completed` then `archived` is NOT auto-chained — instead return 409 with guidance to use `POST /complete` then `POST /archive` (preserving the completed-only precondition). For backward compatibility where the caller expects a soft-archive, allow DELETE only when the project is already `completed`, delegating to `archiveProject`.
- [ ] Document in a code comment that `POST /archive` is the canonical path and DELETE is the superseded alias bound to the same completed-only precondition.
**Acceptance:**
- [ ] `DELETE /api/projects/:id` on a `completed` project archives it via `archiveProject` (same audit + precondition).
- [ ] `DELETE` on an `active` project returns 409 (must complete first) — no path archives a project that skips `completed`.

### Task 7: Archive guard in time-management write paths
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/time/index.ts` (handlers for `POST /api/time/start`, `POST /api/time`)
**Steps:**
- [ ] In `POST /api/time/start` and `POST /api/time` (manual log), after resolving `projectId`, call `await assertProjectWritable(db, tenantId, projectId)` before inserting the entry.
- [ ] On `ProjectArchivedError`, return 409 with code `project_archived` and message "No new time entries on archived projects".
- [ ] Do **not** guard `POST /api/time/:id/stop` for entries already running before archive — stopping an existing running timer is allowed (existing entries are read-only for *edits*, but a running timer must still be stoppable to avoid ghost timers). Existing-entry edits (`PATCH /api/time/:id`) on archived-project entries return 409.
**Acceptance:**
- [ ] Starting or logging time on an archived project returns 409.
- [ ] Time entries on `active`/`completed` projects are unaffected (finalization window preserved).
- [ ] An already-running timer on a project that becomes archived can still be stopped.

### Task 8: Archive guard in tasks write path
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/tasks/index.ts` (handler for `POST /api/tasks`)
**Steps:**
- [ ] In `POST /api/tasks`, when `project_id` is present, call `assertProjectWritable(db, tenantId, project_id)` before insert.
- [ ] Block edits that move a task *into* an archived project (`PATCH /api/tasks/:id` changing `project_id`) — guard the target project.
- [ ] Closing/editing existing tasks already on an archived project: existing tasks are read-only per spec — `PATCH /api/tasks/:id` on a task whose project is archived returns 409 (except internal `completeProject` bulk-close which runs at completion time, before archive).
- [ ] On `ProjectArchivedError`, return 409 code `project_archived`, message "No new tasks on archived projects".
**Acceptance:**
- [ ] Creating a task on an archived project returns 409.
- [ ] Tasks on `completed` projects can still be created/closed (finalization window).
- [ ] Editing an existing task on an archived project returns 409.

### Task 9: Archive guard in invoice create path
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/invoices/index.ts` (handlers for `POST /api/invoices`, `POST /api/invoices/auto-issue`)
**Steps:**
- [ ] In `POST /api/invoices` and `POST /api/invoices/auto-issue`, when `project_id` is present, call `assertProjectWritable(db, tenantId, project_id)` before creating the invoice (blocking *new* invoices linked to archived projects).
- [ ] **Do not** guard invoice lifecycle transitions (`/send`, `/approve`, `/issue-tax`, `/record-payment`, `/credit-note`) — existing invoices on archived projects must continue their lifecycle and collect payment normally (spec: "Invoices linked to archived projects continue to collect payment normally").
- [ ] On `ProjectArchivedError`, return 409 code `project_archived`, message "Cannot create invoices on archived projects".
**Acceptance:**
- [ ] Creating a new invoice linked to an archived project returns 409.
- [ ] `send`/`approve`/`issue-tax`/`record-payment` on existing invoices of an archived project succeed.
- [ ] Invoices with no `project_id` are unaffected.

### Task 10: Completion confirmation sheet (UI)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/projects/CompleteProjectSheet.tsx`
- Create: `apps/zync-app/src/features/projects/hooks/useCompletionSummary.ts`
- Modify: `apps/zync-app/src/features/projects/ProjectDetailHeader.tsx` (add "Mark complete" action)
**Steps:**
- [ ] On "Mark complete", open a `Sheet` that fetches `GET /completion-summary` via `useCompletionSummary` (TanStack Query) and renders the summary block: Tasks (total/done/open), Time (logged/unbilled), Invoices (invoiced/outstanding).
- [ ] Render the warning line when `open_tasks > 0` or `unbilled_hours > 0`: "⚠ {open_tasks} open tasks and {unbilled_hours}h unbilled time".
- [ ] Radio group "What to do with open tasks?": "Leave open" (default, `close_open_tasks=false`) / "Mark all remaining tasks done" (`close_open_tasks=true`). Use the design-system `Radio` with `aria-label`s; group has an accessible legend.
- [ ] "Complete project" calls `POST /complete`; on success invalidate project + task queries and toast success. "Cancel" closes the sheet.
- [ ] Respect `prefers-reduced-motion` for the sheet transition (design-system `Sheet` already honors it — do not override with custom unconditional animation).
**Acceptance:**
- [ ] Summary figures match `GET /completion-summary`.
- [ ] Selecting "Mark all remaining tasks done" sends `close_open_tasks: true`.
- [ ] Radio group is keyboard-navigable and has an accessible legend.

### Task 11: Archive dialog & archived read-only banner (UI)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/projects/ArchiveProjectDialog.tsx`
- Create: `apps/zync-app/src/features/projects/ArchivedProjectBanner.tsx`
- Modify: `apps/zync-app/src/features/projects/ProjectDetailHeader.tsx` (Archive action on `completed`; Unarchive on `archived`)
**Steps:**
- [ ] "Archive" action visible only when `status = 'completed'`. Opens a `Dialog`: "Archive {name}? Archived projects are hidden from default views and locked. No new time entries or invoices after archiving." Show outstanding line when `outstanding_invoice_amount > 0`: "Outstanding: ₪{amount}".
- [ ] "Archive anyway" → `POST /archive`; on success invalidate project queries + navigate/refresh to archived state. "Cancel" dismisses.
- [ ] When `status = 'archived'`, render `ArchivedProjectBanner` at top of detail: "🗄 Archived · Archived {archived_at} · Completed {completed_at}" with an "[Unarchive]" button → `POST /unarchive` (gated client-side on `projects:write`).
- [ ] Archived detail page is read-only: disable edit/create controls (tasks, time, invoice-create) in the UI; backend guards (Tasks 7-9) are the source of truth.
- [ ] `Dialog` uses `role="alertdialog"`, focus-trapped, Escape-cancellable; banner uses `role="status"`. Respect `prefers-reduced-motion`.
**Acceptance:**
- [ ] Archive action only shows on `completed` projects; Unarchive only on `archived`.
- [ ] Outstanding amount appears in the dialog when > 0.
- [ ] Archived banner shows both dates and a working Unarchive button.

### Task 12: `/projects` list status filter (UI)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/features/projects/ProjectList.tsx`
- Modify: `apps/zync-app/src/features/projects/hooks/useProjectList.ts`
**Steps:**
- [ ] Add a status filter control: Active · Completed · Archived. Default selection shows **Active + Completed**; Archived hidden unless explicitly toggled ("Show archived").
- [ ] Wire the selection to the existing `GET /api/projects` list query's status param (comma-separated statuses); persist selection in URL search params for bookmarkability.
- [ ] Render a "Completed" badge on completed rows/cards and an "[Archived]" badge on archived ones (when shown), using design-system `Badge`.
- [ ] Filter control is keyboard-operable with accessible labels.
**Acceptance:**
- [ ] Default list excludes `archived`; toggling "Show archived" includes them.
- [ ] Completed and archived items carry the correct badge.
- [ ] Filter state survives reload via URL params.

### Task 13: Archived badge in global search results (UI)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/features/search/ProjectSearchResult.tsx`
**Steps:**
- [ ] When a project search result has `status = 'archived'`, append an `[Archived]` badge to its result row (design-system `Badge`).
- [ ] Archived projects remain searchable and link to their read-only detail page.
**Acceptance:**
- [ ] Archived projects appear in search with an `[Archived]` badge.
- [ ] Clicking an archived result opens its read-only detail view.
