# Project Settings — Implementation Plan

**Spec:** docs/specs/2026-06-01-settings-projects.md  ·  **Slug:** settings-projects  ·  **Wave:** 7
**Depends on:** foundation-auth-rbac, project-hourly-budget, projects-module

## Goal
Add a `/settings/projects` page where OWNER/ADMIN configure tenant-wide defaults that pre-fill the "New Project" form: default billing type, hourly rate, currency, time-tracking toggle, time rounding, and budget-alert threshold/channels. These are scalar tenant-level defaults stored on the canonical `tenant_settings` table (owned upstream by `expenses-module`). They affect only the new-project form; they never retroactively change existing projects. This closes the gap where `projects-module` (`billing_config` JSONB) and `project-hourly-budget` (`budget_alert_pct`) defined per-project config but nothing defined tenant defaults.

## Architecture
- **Schema:** Eight scalar columns added to the existing `tenant_settings` table (base: `id` UUID PK, `tenant_id` UUID UNIQUE → `tenants(id)`, timestamps), owned by `foundation-auth-rbac`. This plan only runs `ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS` statements. No new table.
- **Data flow:** `GET /api/settings/projects` reads the eight columns (guarded by `projects:read`); `PATCH /api/settings/projects` writes any subset (guarded by `projects:write`). Settings page (React island in the app) renders three card sections (New Project Defaults, Time Tracking, Budget Alerts) and saves via PATCH.
- **Pre-fill wiring (cross-module):** the `projects-module` `/projects/new` create sheet reads these defaults to seed its form — billing type, hourly rate (only if > 0), currency, track-time toggle, and `billing_config.budget_alert_pct` (per `project-hourly-budget`'s JSONB shape).
- **Upstream consumed:** `tenant_settings` table, `tenants` table, `authMiddleware`, `requirePermission`, `tenantQuery`, `require-zod-validation-in-routes`, `buildPaginated` (N/A here), Drizzle `createDb`/`Db` from `@zync/db`; UI primitives `Card`, `Radio`, `Input`, `Select`, `Switch`, `Checkbox`, `Button`, `Form`, `FormField`, `FormLabel`, `FormError`, `toast`, `Skeleton` from `@zync/ui`; `projects:read` / `projects:write` permissions from `foundation-auth-rbac`.

## Tech Stack
- **API:** Hono routes in `apps/zync-api` (Cloudflare Workers), Drizzle ORM over Neon Postgres via Hyperdrive, Zod validation.
- **DB package:** `@zync/db` — extend the `tenantSettings` Drizzle table definition and export query helpers.
- **App:** `apps/zync-app` (Vite + React), TanStack Query hook, React Hook Form + Zod, `@zync/ui` components.
- **Bindings:** Hyperdrive (`HYPERDRIVE`/`DB`), standard `Env`. No KV/R2/Queue needed.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 7a | 1 | `packages/db/src/schema/tenant-settings.ts`, migration SQL | No (schema first) |
| 7b | 2, 3 | `packages/db/src/queries/project-settings.ts`, `packages/types/src/project-settings.ts` | Yes (after 1) |
| 7c | 4 | `apps/zync-api/src/routes/settings/projects.ts` | No (after 2,3) |
| 7d | 5, 6 | `apps/zync-app/src/features/settings/projects/*` | Yes (after 4) |
| 7e | 7 | `apps/zync-app/src/features/projects/new/*` (pre-fill wiring) | Yes (after 4) |

## Tasks

### Task 1: Schema delta — extend `tenant_settings` with project defaults
**Blocks:** 2, 4  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/tenant-settings.ts` (Drizzle table owned by expenses-module; add columns)
- Create: `packages/db/migrations/<timestamp>_settings_projects_defaults.sql`
**Steps:**
- [ ] `tenant_settings` base table is owned by `foundation-auth-rbac` (`id` UUID PK, `tenant_id` UUID UNIQUE → `tenants(id)`, timestamps) and is in this plan's closure. Do NOT recreate it.
- [ ] Add the eight `ADD COLUMN IF NOT EXISTS` statements (verbatim from spec) to a new migration file.
- [ ] Add matching Drizzle column definitions to the `tenantSettings` table object: `text`/`numeric`/`boolean`/`integer` builders, each with `.notNull()` and `.default(<column default>)`, and CHECK constraints declared via `check()` in the table's third-argument callback so the generated DDL matches the SQL below.
- [ ] Apply the migration to a Neon branch and confirm `\d tenant_settings` shows all eight new columns with their CHECK constraints.
**Schema / Interfaces:**
```sql
-- tenant_settings base table is OWNED by foundation-auth-rbac (do not CREATE here).
-- Canonical base definition for reference:
--   CREATE TABLE tenant_settings (
--     id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
--     tenant_id UUID NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
--     <module-owned columns added via ALTER by each settings plan>,
--     created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
--     updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
--   );

ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS project_default_billing_type TEXT NOT NULL DEFAULT 'fixed'
    CHECK (project_default_billing_type IN ('fixed', 'hourly', 'retainer')),
  ADD COLUMN IF NOT EXISTS project_default_hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 0,
  ADD COLUMN IF NOT EXISTS project_default_currency TEXT NOT NULL DEFAULT 'ILS',
  ADD COLUMN IF NOT EXISTS project_default_time_tracking BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS project_default_time_rounding_minutes INTEGER NOT NULL DEFAULT 0
    CHECK (project_default_time_rounding_minutes IN (0, 5, 15, 30)),
  ADD COLUMN IF NOT EXISTS project_default_budget_alert_pct INTEGER NOT NULL DEFAULT 80
    CHECK (project_default_budget_alert_pct BETWEEN 1 AND 100),
  ADD COLUMN IF NOT EXISTS project_budget_alert_email BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS project_budget_alert_in_app BOOLEAN NOT NULL DEFAULT true;
```
**Acceptance:**
- [ ] Migration applies cleanly and is idempotent (`IF NOT EXISTS` allows re-run with no error).
- [ ] All enums are inline `CHECK`; the rate is `NUMERIC(10,2)`; `project_default_time_rounding_minutes` is `INTEGER` with `CHECK (project_default_time_rounding_minutes IN (0,5,15,30))`; `project_default_budget_alert_pct` is `INTEGER` with `CHECK (project_default_budget_alert_pct BETWEEN 1 AND 100)`; both alert-channel columns are `BOOLEAN`.
- [ ] Drizzle `tenantSettings` table object selects/inserts the eight new columns without type errors.

### Task 2: Query helpers — `getProjectDefaults` / `updateProjectDefaults`
**Blocks:** 4  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/project-settings.ts`
- Modify: `packages/db/src/index.ts` (export the two helpers)
**Steps:**
- [ ] Implement `getProjectDefaults(db, tenantId)`: select the eight columns from `tenant_settings` where `tenant_id = tenantId`. If no row exists, upsert a default row (`INSERT (tenant_id) VALUES ($1) ON CONFLICT (tenant_id) DO NOTHING`) then re-select, so first-time tenants get the column defaults rather than `undefined`.
- [ ] Implement `updateProjectDefaults(db, tenantId, patch)`: update only the provided keys, set `updated_at = now()`, and use `INSERT (tenant_id, <patched columns>) VALUES (<patched values>) ON CONFLICT (tenant_id) DO UPDATE SET <patched columns>` so a missing row is created. Return the full updated `ProjectDefaults`.
- [ ] Do NOT reuse `getTenantSettings`/`upsertTenantSettings` from the locked sheet — those operate on `ai_tenant_settings` (AISettings). These are purpose-specific helpers.
- [ ] Coerce `NUMERIC` `project_default_hourly_rate` to a JS `number` on read (Drizzle returns numeric as string by default).
**Schema / Interfaces:**
```typescript
import type { Db } from '@zync/db';
import type { ProjectDefaults, ProjectDefaultsPatch } from '@zync/types';

export async function getProjectDefaults(db: Db, tenantId: string): Promise<ProjectDefaults>;
export async function updateProjectDefaults(
  db: Db,
  tenantId: string,
  patch: ProjectDefaultsPatch,
): Promise<ProjectDefaults>;
```
**Acceptance:**
- [ ] `getProjectDefaults` returns column defaults for a tenant with no prior settings row.
- [ ] `updateProjectDefaults({ project_default_hourly_rate: 150 })` persists only that field; unspecified fields keep prior values.
- [ ] `project_default_hourly_rate` is returned as a `number`, not a string.

### Task 3: Shared types & Zod schemas
**Blocks:** 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/project-settings.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define the `ProjectDefaults` type with all eight fields.
- [ ] Define `projectDefaultsPatchSchema` (Zod) — every field optional, mirroring the column CHECKs: billing type enum, currency string (3-letter, default-aware), non-negative rate, time-tracking boolean, rounding enum, alert pct 1–100 integer, two booleans. This is the request-body validator for PATCH (satisfies `require-zod-validation-in-routes`).
- [ ] Export `ProjectDefaultsPatch = z.infer<typeof projectDefaultsPatchSchema>`.
**Schema / Interfaces:**
```typescript
import { z } from 'zod';

export type ProjectBillingType = 'fixed' | 'hourly' | 'retainer';
export type ProjectTimeRounding = 0 | 5 | 15 | 30;

export interface ProjectDefaults {
  project_default_billing_type: ProjectBillingType;
  project_default_hourly_rate: number;          // NUMERIC(10,2) >= 0
  project_default_currency: string;             // ISO-4217, e.g. 'ILS'
  project_default_time_tracking: boolean;
  project_default_time_rounding_minutes: ProjectTimeRounding;
  project_default_budget_alert_pct: number;     // 1..100
  project_budget_alert_email: boolean;
  project_budget_alert_in_app: boolean;
}

export const projectDefaultsPatchSchema = z.object({
  project_default_billing_type: z.enum(['fixed', 'hourly', 'retainer']).optional(),
  project_default_hourly_rate: z.number().min(0).max(99_999_999.99).optional(),
  project_default_currency: z.string().length(3).optional(),
  project_default_time_tracking: z.boolean().optional(),
  project_default_time_rounding_minutes: z
    .union([z.literal(0), z.literal(5), z.literal(15), z.literal(30)])
    .optional(),
  project_default_budget_alert_pct: z.number().int().min(1).max(100).optional(),
  project_budget_alert_email: z.boolean().optional(),
  project_budget_alert_in_app: z.boolean().optional(),
}).strict();

export type ProjectDefaultsPatch = z.infer<typeof projectDefaultsPatchSchema>;
```
**Acceptance:**
- [ ] Patch schema rejects `project_default_budget_alert_pct: 0` and `: 101`, and unknown keys (`.strict()`).
- [ ] Patch schema accepts an empty `{}` (no-op) and any single-field subset.

### Task 4: API routes — `GET`/`PATCH /api/settings/projects`
**Blocks:** 5, 7  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/settings/projects.ts`
- Modify: `apps/zync-api/src/routes/settings/index.ts` (mount the sub-router)
**Steps:**
- [ ] Mount under the authenticated settings router; apply `authMiddleware` so `tenant_id` is resolved from the session.
- [ ] `GET /api/settings/projects` → `requirePermission('projects:read')`, call `getProjectDefaults(db, tenantId)`, return the eight fields as JSON.
- [ ] `PATCH /api/settings/projects` → `requirePermission('projects:write')`, validate body with `projectDefaultsPatchSchema` (return 400 with field errors on failure), call `updateProjectDefaults(db, tenantId, patch)`, return the full updated object.
- [ ] Use `tenantQuery`/the tenant-scoped Drizzle client; never call raw Drizzle directly from the route (honor `no-raw-drizzle-from-routes`).
- [ ] Preserve standard security headers/CSP from the API middleware stack; do not weaken them.
**Schema / Interfaces:**
```
GET   /api/settings/projects   (projects:read)
  → 200 ProjectDefaults
PATCH /api/settings/projects   (projects:write)
  body: ProjectDefaultsPatch (validated by projectDefaultsPatchSchema)
  → 200 ProjectDefaults
  → 400 { error, fieldErrors } on validation failure
  → 403 if caller lacks projects:write
```
**Acceptance:**
- [ ] Caller with only `projects:read` gets 200 on GET, 403 on PATCH.
- [ ] PATCH with invalid `project_default_time_rounding_minutes: 10` returns 400, persists nothing.
- [ ] Successful PATCH returns the full updated `ProjectDefaults`, not just the patched subset.
- [ ] Settings are tenant-scoped — one tenant cannot read or write another tenant's defaults.

### Task 5: Settings page — `/settings/projects` UI
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/projects/ProjectSettingsPage.tsx`
- Create: `apps/zync-app/src/features/settings/projects/useProjectDefaults.ts`
- Modify: `apps/zync-app/src/features/settings/routes.tsx` (register `/settings/projects`, gate on `projects:write`)
**Steps:**
- [ ] `useProjectDefaults()` — TanStack Query hook: `useQuery` GET `/api/settings/projects`; `useMutation` PATCH with optimistic invalidation of the query key on success; surface errors via `toast`.
- [ ] Build the page with React Hook Form + `projectDefaultsPatchSchema` resolver, rendering three `Card` sections matching the spec layout:
  - **New Project Defaults:** `Radio` group billing type (Fixed price / Hourly / Retainer); hourly-rate `Input` (numeric, "leave 0 for no pre-fill" helper); currency `Select` (default ILS).
  - **Time Tracking:** `Radio`/`Switch` "Enable time tracking on new projects by default" (Yes/No); time-rounding `Select` (None / 5 min / 15 min / 30 min mapping to 0/5/15/30).
  - **Budget Alerts:** percent `Input` (1–100, helper "applies to new hourly projects"); two `Checkbox`es — "In-app notification (OWNER + ADMIN)" → `project_budget_alert_in_app`, "Email to OWNER" → `project_budget_alert_email`.
- [ ] Single `[Save changes]` `Button` PATCHes only dirty fields; show success `toast` on save.
- [ ] Show `Skeleton` while loading; render fetch errors via the shared error state.
- [ ] Route guard requires `projects:write`; users without it do not see the page or are redirected.
- [ ] A11y: each control has an associated `FormLabel`; radio groups use `role="radiogroup"`; checkboxes are keyboard-operable; respect `prefers-reduced-motion` for the save transition. Use logical CSS properties so the layout mirrors correctly under RTL/Hebrew (no hardcoded left/right). Use design-token spacing/colors only (honor `no-hardcoded-colors`, `no-hardcoded-spacing`).
**Acceptance:**
- [ ] Page renders the three sections; values load from the API and round-trip through Save.
- [ ] Currency `Select` shows ILS selected by default; rounding `Select` maps labels to 0/5/15/30 correctly.
- [ ] Save sends only changed fields and shows a success toast; validation errors render inline via `FormError`.
- [ ] Page is reachable only with `projects:write`; renders correctly in RTL/Hebrew and with reduced motion.

### Task 6: Settings navigation entry
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/features/settings/SettingsNav.tsx` (or the settings sidebar manifest)
**Steps:**
- [ ] Add a "Projects" item to the Settings navigation linking to `/settings/projects`, visible only when the user has `projects:write`.
- [ ] Order it alongside the other module settings entries (e.g. after Expenses/Business), localized label.
**Acceptance:**
- [ ] "Projects" appears in Settings nav for OWNER/ADMIN with `projects:write`; hidden otherwise.
- [ ] Clicking it routes to `/settings/projects`.

### Task 7: Pre-fill wiring in `/projects/new` create form
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/features/projects/new/NewProjectSheet.tsx` (the `Sheet` create form from projects-module)
- Modify: `apps/zync-app/src/features/projects/new/useNewProjectDefaults.ts` (new helper or extend existing form-init hook)
**Steps:**
- [ ] On opening the new-project sheet, fetch `GET /api/settings/projects` (reuse `useProjectDefaults` query) and seed the form initial values per the spec's Pre-fill Behavior table:
  - Billing type selector ← `project_default_billing_type`.
  - Hourly rate input ← `project_default_hourly_rate` **only if > 0** (0 means "no pre-fill" — leave blank).
  - Currency selector ← `project_default_currency`.
  - "Track time" toggle ← `project_default_time_tracking`.
  - Budget alert % ← `project_default_budget_alert_pct`, written into `billing_config.budget_alert_pct` (the per-project JSONB field defined by `project-hourly-budget`) when billing type is `hourly`.
- [ ] Pre-fill is initial-state only; the user can override every field before submit. Submitting still writes per-project `billing_config` (rate_per_hour, budget_alert_pct, etc.) via the existing `POST /api/projects` path — settings are not re-read on edit and never mutate existing projects.
- [ ] When defaults haven't loaded yet, fall back to the form's prior hardcoded defaults so the sheet is never blocked.
**Acceptance:**
- [ ] Opening `/projects/new` pre-selects the tenant's default billing type and currency, and pre-fills the track-time toggle.
- [ ] With `project_default_hourly_rate = 0`, the rate field is empty; with `150`, it shows 150.
- [ ] Choosing hourly billing seeds `billing_config.budget_alert_pct` from the tenant default; user override is respected on submit.
- [ ] Editing an existing project does not pull these defaults (no retroactive change).
