Skip to main content
PLACEHOLDER COPY — pending legal review.

Changelog

Last updated: June 10, 2026

What's new, what changed, and what's been fixed in Sparkd. Released versions are listed newest first.

Unreleased

Added

Added
  • **Quotes module (Phase 1).** Full client-facing quote lifecycle: create/edit/duplicate/delete drafts with line items, per-line or quote-level tax, and discounts (shared `computeInvoiceTotals()` math); status flow `draft → sent → accepted / declined`, plus a stored `expired` status; branded PDF (snapshot-on-send to the private `invoices` bucket) and 5-locale quote email with the PDF attached; 1-click quote→invoice conversion; manual accept/decline overrides with accepted-by name + timestamp/IP capture and an online-accept link. Quote numbers share the fiscal-year sequence machinery with invoices (`invoice_sequences` `kind='quote'`, format `${PREFIX}-${YEAR}-${0000}`). New `/quotes` list (KPI strip + create sheet) and `/quotes/[id]` detail page with inline draft line-item editing and an activity timeline. Owner-editable quote settings (prefix, validity window, default terms) live on Settings → Invoices with an honest next-number preview. Gated by the existing `vatInvoicing` feature; manager+ read, owner-only settings. Spec: `tasks/active/sparkd-quotes-feature.md`.
  • **Quotes module (Phase 2).** Public no-login accept page at `/quote/{token}` (server-rendered, `noindex`, org-branded; shows status banner + full quote body and, while open, accept/decline actions). New public server actions `getPublicQuote` (service-client read) and `respondToQuote` (accept with typed name / decline with optional reason; captures IP + user-agent, optimistic `sent`-only status guard against double-resolution and past-validity acceptance, audit-logged via `public_link` with no user). Sent-quote emails embed the accept link only while the quote is open. Daily `quote-expiry` cron (`/api/crons/quote-expiry`, 06:00 UTC) flips `sent` quotes past their `valid_until` to the stored `expired` status. Spec: `tasks/active/sparkd-quotes-feature.md`.
  • **Quotes module (Phase 3).** Four acceptance/conversion upgrades. **(1) Deposits:** per-quote `deposit_percent` (org default `quote_deposit_percent`) auto-creates a best-effort, idempotent, non-blocking single-line **draft deposit invoice** on acceptance (manager or public), linked via `quotes.deposit_invoice_id`; surfaced on the detail totals and as a public-page notice. **(2) Drawn e-signature:** the public accept dialog has a touch/pointer signature canvas; the PNG (≤~400 KB) is stored at `{org_id}/quote_{id}_signature.png` in the private `invoices` bucket and rendered on the detail acceptance block via an 8h signed URL (optional — accepting unsigned still works). **(3) Optional line items:** `quote_items.is_optional`/`is_selected`; the client toggles add-ons on the public page with a live-recomputed total, the server marks selections and recomputes the quote total (only selected lines count) before the deposit calc. **(4) Quote → job conversion:** `convertQuoteToJob` (manager-gated, idempotent via `quotes.converted_job_id`) reuses the `createJob` pipeline (carries client, notes, total as price, `isQuoted=true`); the detail page gains a "Schedule job" date/time dialog and a "View job" link once converted. Migration `20260710000001_quotes_phase3.sql`. Spec: `tasks/active/sparkd-quotes-feature.md` (#13–16).
  • **Credit-note PDF + email.** Credit notes can now be downloaded as a branded classic-style PDF (snapshot-on-send to the private `invoices` bucket so the client copy is immutable) and emailed to the client with the PDF attached (5-locale template, 4-tier recipient resolution). Unissued credit notes are deletable from the detail page. Credit notes are netted out of the VAT summary (per rate) and the aged-receivables report (per invoice); both reports now also exclude soft-deleted invoices.
  • **Aged-receivables CSV export** with a UTF-8 BOM (Excel/de-CH safe). Report "today" is now computed in the org's timezone rather than UTC, and the desktop table surfaces each invoice's due date.
  • **Invoice discounts, end-to-end.** Absolute-cents discount taken off the subtotal *before* tax, distributed proportionally across taxable lines. Editable on the create sheet and on draft detail pages (amount + optional reason); rendered on all three PDF templates (classic/minimal/bold); netted into the VAT export. All money math now flows through a single source of truth, `computeInvoiceTotals()` (`src/lib/invoices/compute-totals.ts`), used by create, update/recompute, the VAT summary, and every PDF template.
  • Localized send-dialog subject preview on the invoice detail page (all 5 locales).
  • Cleaner voice notes via Web Speech API (graceful degrade where unsupported).
  • Auto clock-out cron (opt-in per user) closes long-running open shifts past `clock_in + duration + 60min` with email notification.
  • Running-late status: cleaner can flag a job as late with preset chips (5/10/15/30) or custom minutes; managers receive email fan-out.
  • Receipt OCR scaffolding (no provider wired yet; `extract-receipt.ts` returns nulls + warning).
  • Receipt-replace indicator: managers see "Updated N×" badge when an expense receipt has been re-uploaded.
  • Time-off duplicate warning (debounced lookback 30d) and remaining-balance forecast on submission.
  • Maps deeplink "Directions from previous job" between consecutive jobs in the cleaner day list.
  • GDPR self-export scope tooltip on `/settings/privacy`.
  • Soft-lock banner on cleaner pages (`/time-off`, `/expenses`, `/hours`) explaining write-blocks instead of a generic toast.
  • Camera-icon affordance next to receipt file input on the expense submit sheet.
  • Opportunistic LRU cache for storage signed URLs (`src/lib/storage/signed-url-cache.ts`) — saves ~960 createSignedUrl calls/cleaner/day on warm functions.

Changed

Changed
  • **Timesheet net-minutes unified through a single source of truth.** `netMinutes()` in `src/lib/timesheet/calc.ts` is now the one place that computes "net worked minutes" — it applies BOTH punch-rounding (the `org_timesheet_settings.rounding_minutes` setting, previously read by the settings UI but never actually applied to any total) AND auto-break, and every consumer routes through it: the manager timesheets page + table + entry-sheet preview, the calendar rollup, the cleaner `/hours` list/heatmap/manual-entry preview, the weekly attestation snapshot, the CSV export (detail + payroll), `getVarianceReport`, and the daily/weekly digest crons (now per-org settings). New `grossMinutes()` helper applies rounding consistently for gross columns. **Behavior note:** `rounding_minutes` defaults to `0` (off) and `auto_break_enabled` to `false`, so existing orgs see no change in their numbers until they opt in via Timesheet settings — this change activates a previously-inert setting rather than altering current totals.
  • Timesheet detail CSV export now uses tz-aware week bounds and renders clock-in/out in the exporting manager's timezone (was UTC, which showed every punch at the wrong hour for non-UTC operators); dropped the "(UTC)" header suffixes.
  • `/team/timesheets/calendar` role gate lowered from `manager` to `team_leader` for consistency with the main timesheets page and `ROUTE_PERMISSIONS` (the proxy already prefix-matches team leaders into the route; the team-scope filter on the page narrows visibility to the leader's own team).
  • Unified completion-CTA i18n key across `cleaner-job-card` and `active-job-bar`.
  • Avatar upload now content-hashes the filename and removes the prior object — no more orphan accumulation.
  • Time-off decision email formats `reviewed_at` via `Intl` with explicit tz label.
  • Empty states across 8 dashboard pages now vertically center in the viewport instead of pinning under the page header.
  • Content wrappers on 4 dashboard pages gained `mx-auto` so populated views are centered on desktop.
  • Regenerated `src/lib/db/types.ts` from the live schema — drops 5 stale `TODO(types)` markers and 4 `(supabase as any)` call-site casts in `schedule/page.tsx`, `jobs/page.tsx`, and `actions/jobs.ts` that masked real type-checking on `jobs.is_urgent`, `jobs.series_id`, `cleaner_blocks`, and `user_profiles.staff_view_order`.
  • ESLint config now ignores `.vercel/**`, `storybook-static/**`, and the generated `db/types.ts`. Removes ~240 build-artifact lint warnings that drowned out real source findings.
  • Documented the auth-stack carve-outs (`auth.ts`, `admin-*.ts`, `public-locale.ts`, `tickets.ts`) in CLAUDE.md §5.2 + at the top of `tickets.ts`. Future contributors won't try to "fix" the intentional `requireSession()` bypass that lets users in `/oauth-org-setup` and soft-locked owners reach support.

Fixed

Fixed
  • **Partial invoice updates no longer clobber untouched columns.** `updateInvoice` now detects which fields the caller actually sent from the raw input keys instead of the post-Zod values — the schema's `.optional().transform()` chains coerce omitted `dueDate`/`notes`/`discountReason` to `null`, which made the old `=== undefined` guards always fire. Net effect: notes-only edits work again on sent/paid invoices (were wrongly rejected as `invoice_not_draft`), and `due_date`/`discount_reason` are written only when actually provided.
  • Invoice recompute on update now nets the discount and honors per-line tax rates (the old inline math applied a flat invoice rate to a stale subtotal and dropped the discount entirely).
  • PDF discount row uses an ASCII hyphen instead of U+2212 MINUS SIGN, which the built-in Helvetica/WinAnsi font cannot render.
  • Overdue-reminder cron queries `status = 'sent'` only (`past_due` is not a real status in this app) and reports `skipped_status_changed` separately from `skipped_no_recipient` when an invoice races to a terminal state mid-tick.
  • `/jobs/[id]` formatters now accept a `tz` parameter; cleaners no longer see scheduled times shifted 1–2h vs `/jobs/today`.
  • `getHolidaySet` failures now surface as `fetch_failed` instead of silently producing wrong working-day counts.
  • Time-off attachments are now Zod-parsed on read, falling back to `[]` on shape drift instead of crashing the UI.
  • Photo-upload race hardened with `Math.max(0, …)` guard and explicit comment.
  • Platform-admin shield icon in the top bar now appears for any user in the `platform_admins` table — previously gated on a hardcoded `email === "admin@lopes2tech.ch"`, which silently hid the icon for every other admin. New `isPlatformAdmin(userId)` helper resolves server-side in the dashboard layout and is forwarded to the top bar; `/admin` pages still re-check via `requireAdminSession()`.
  • Platform admin org list now shows test orgs (`is_test=true`) by default, badged with 🧪. Previously the list defaulted to hiding them, which conflated the org-list view with the MRR aggregates. They are decoupled now: the list shows every org for visibility/QA/support, while MRR / billing / churn endpoints in `admin-billing.ts` still exclude test orgs unconditionally. URL param `?includeTest=1` renamed to `?hideTest=1` (opt-out); header changes to `(test orgs hidden)` in muted gray when the toggle is on.

Changed

Reschedule requests — audit-driven fixes
  • **Team leaders can now see and act on reschedule requests from their team.** Migration `20260701000001_reschedule_requests_team_leader_rls.sql` widens the SELECT + UPDATE RLS policies on `job_reschedule_requests` to threshold at `team_leader` (role rank ≥ 2), matching the team-leader grant pattern used elsewhere. Previously the policies were manager+ only, so a team_leader who owns the assignment could neither view nor approve/reject a cleaner's request — a latent gap given team_leaders already manage their team's schedule.
  • **Team leaders are notified when one of their team submits a request.** `submitRescheduleRequest` previously fanned out only to managers/owners; it now also resolves the requester's `team_id → teams.team_leader_id` and enqueues a `reschedule.requested` notification to that leader (deduped via a recipient `Set`, requester excluded).
  • **Approve re-checks job status before rescheduling.** `approveRescheduleRequest` now re-reads the target job's `status`/`deleted_at` and returns `not_reschedulable` if the job was completed, cancelled, or soft-deleted between submission and approval — closes a TOCTOU window where a stale pending request could mutate a finished job.
  • **Cleaners now see the outcome of their request.** `/jobs/[id]` fetches the caller's latest request for the job and `CleanerJobDetailView` renders three states: an amber "awaiting decision" chip while pending, a rose hint banner (with the manager's decision note) when rejected, and the plain reschedule action otherwise. Previously a rejected/approved outcome was invisible to the cleaner and the reschedule link showed unconditionally even with a request already pending.
  • **Past-date guard on submission.** `submitRescheduleRequest` rejects a proposed date earlier than today in the submitter's timezone (`date_in_past`), tz-aware via `localDateISO(new Date(), submitterTz)`, with a client-side mirror in the dialog for snappy feedback (the tz-agnostic Zod schema can't enforce it).
  • **Timezone threaded into the cleaner reschedule dialog.** `CleanerJobCard` now forwards the cleaner's tz to `RescheduleRequestDialog` so the seeded date/time defaults render in the cleaner's local zone instead of UTC.
  • **`alreadyPending` copy reworded job-centric** ("There's already a pending reschedule request for this job") — the prior wording was cleaner-centric and misleading when a teammate had already requested.
  • Removed dead `listMyRescheduleRequests` / `getMyPendingRescheduleRequestForJob` actions — the cleaner-side read is now an inline query in `/jobs/[id]/page.tsx`.

Changed

Equipment — audit-driven fixes
  • **Custom categories no longer crash the detail page or the cleaner equipment list.** Both surfaces called `t(`equipment.categories.${slug}`)` directly, which throws in next-intl when the slug is a manager-created custom category (only the 6 builtins have i18n keys). Introduced a shared client-safe resolver `src/lib/equipment/category-display.ts` (`categoryDisplayName` + `categoryIconKey`) and routed the detail page, the cleaner list (`/equipment`), and the manager list (`equipment-client.tsx`) through it — builtins resolve via i18n, customs fall back to the category row's `name`/`iconKey`, unknown slugs humanize gracefully. Both pages now fetch `listEquipmentCategories()`; `EquipmentDetail.category` widened from a builtin union to `string`.
  • **Category icons now reflect custom categories.** `<CategoryIcon>` was passed the raw slug on the detail + cleaner-list pages, so custom categories always rendered the fallback "Package" glyph; they now resolve through `categoryIconKey` to the manager-chosen icon.
  • **Purchase price respects the org's currency.** The equipment detail page hardcoded `CHF`; it now reads `organizations.default_currency` and formats accordingly (defaults EUR).
  • **Check-out / check-in timestamps render in the viewer's timezone.** The detail page's `formatDateTime` had no `timeZone`, so it used the server zone (landmine #31); it now threads `getUserTimezone()`.
  • **`logMaintenance` verifies the equipment belongs to the caller's org** before inserting the maintenance log, matching every other mutation in the file (the FK alone had no org cross-check).
  • **Both equipment list pages are now horizontally centered** on desktop (`mx-auto w-full` on the content wrapper), matching the centered header and the `/expenses` / `/time-off` convention.
  • **Stat cards are now one-tap status filters.** The manager equipment stat bar (active / in maintenance / retired / overdue) moved into `EquipmentClient`; each card toggles a filter on the list (tap again to clear) and is a keyboard-focusable 44px+ button with an active-ring state.
  • **Warranty-expiring badge.** Equipment whose warranty lapses within 30 days now shows a blue "Warranty expiring" badge in the manager list (new `equipment.warrantyExpiring` key × 5 locales), surfacing coverage that's about to end before it does.

Changed

Equipment — filter layout + category management
  • **Search row + scrollable filter chips.** The manager equipment toolbar previously crammed the search box and every category chip onto one row, squeezing search to a few characters and wrapping chips into the stat bar. Search now sits full-width on its own row; the "Todos" + category chips moved to a single horizontally-scrollable rail below (hidden scrollbar, edge-fade mask, swipe/trackpad scroll), so the taxonomy can grow without breaking the layout.
  • **Manage categories.** New manager-only sheet (launched from a "Manage" button pinned beside the filter rail) to curate the org's equipment taxonomy: reorder any category via up/down buttons (optimistic, persisted), hide/unhide any category (builtins included — soft and reversible, never deletes), rename + re-icon custom categories, create (reuses the existing create dialog), and delete custom categories (existing in-use guard). Builtins show a lock affordance — they stay hide+reorder only because their labels resolve through i18n per viewer.
  • **Hide unused builtins.** Categories can now carry `is_hidden` (new migration `20260708000001_equipment_categories_hidden.sql`). Hidden categories drop out of the filter chips and the add/edit-equipment picker, but equipment already assigned to a hidden category still renders its label + icon (the picker also keeps a hidden category visible when it's the item's current selection). Hiding the currently-active filter chip resets the filter to "All" so it can't get stuck on an invisible category. New server actions `updateEquipmentCategory`, `setEquipmentCategoryHidden`, `reorderEquipmentCategories`; new error codes `builtin_protected`, `category_in_use`, `category_update_failed`, `reorder_failed` + `equipment.manageCategories.*` strings × 5 locales.
  • **Wider icon picker for custom categories.** The create + edit dialogs grew from 6 builtin-tied glyphs to 16 — added generic equipment icons (brush, spray, bucket, trash, box, wrench, plug, battery, scissors, textile) so custom categories can pick a distinct icon. Glyph map, `EQUIPMENT_ICON_KEYS`, and the validator `ICON_KEYS` enum kept in lockstep; aria-label keys added under `equipment.categories.*` × 5 locales.

Changed

Tests
  • Added [tests/unit/lib/timesheet/calc.test.ts](tests/unit/lib/timesheet/calc.test.ts) — 16 cases pinning the now-central `grossMinutes`/`netMinutes` helpers: rounding-off no-op (proves existing orgs are unaffected), both-punch rounding, auto-break taking the larger of explicit/auto break (never stacking), combined rounding+auto-break, and the negative/zero-span guards. The calc lib previously had no coverage.
  • Added [tests/unit/lib/team-leader-routing.test.ts](tests/unit/lib/team-leader-routing.test.ts) — pins down the `team_leader` role between cleaner and manager and the critical `/team/expenses` vs `/team` prefix-ordering that prevents cleaners from being silently let through if `ROUTE_PERMISSIONS` is reordered.
  • Added [tests/unit/lib/job-lifecycle.test.ts](tests/unit/lib/job-lifecycle.test.ts) — pins the forward-only `assigned → in_progress → completed` transition map; a regression here would let a cleaner skip clock-in or re-open a completed job.
  • Added [tests/unit/lib/role-routing.test.ts](tests/unit/lib/role-routing.test.ts) — pins `resolveRoleDestination` so the post-login landing for owner/manager/team_leader/cleaner is enforced in CI.
  • Added [tests/unit/app/cron-registration.test.ts](tests/unit/app/cron-registration.test.ts) — guard test asserting `vercel.json crons[]` and `src/app/api/crons/*` are bidirectionally synced. Catches the "new cron route never registered" failure mode.
  • 75 new test cases total. Test mocks tightened with documented `eslint-disable` directives instead of bare `any` (the Supabase fluent API has 50+ chainable methods; expressing it in test types costs more than it pays).

Changed

Tooling
  • Added [scripts/seed_demo_swiss_co.py](scripts/seed_demo_swiss_co.py) — generates a "Swiss Brightness AG" demo tenant simulating a 59-staff Swiss cleaning company. Multi-cultural workforce (Portuguese / Swiss-German / Albanian / Italian / Tamil / Spanish / French) drives the i18n surface end-to-end; covers all 11 entity classes including invoices, credit notes, expenses, equipment + maintenance + assignments. 11 idempotent phases with sub-phase resume safety, dry-run mode, partial-failure recovery. Cleanup is one SQL line: `DELETE FROM organizations WHERE is_test=true AND name='Swiss Brightness AG'`.

Changed

Tickets — two-way support loop
  • **User-side ticket inbox** at [/settings/support](src/app/(dashboard)/settings/support/page.tsx) and per-ticket detail page. Users see their own submissions with status badges and admin replies; reply-count chip on the list flags unread responses. Visible from the Settings sidebar, accessible to soft-locked owners + pre-onboarding users (the auth carve-out is preserved).
  • **Admin replies + email-on-reply.** New `is_internal` flag on `ticket_notes` (migration `20260620000001_ticket_notes_is_internal.sql`) lets the admin form choose "🔒 Internal note" vs "✉️ Reply to user". Public replies email the submitter via Resend ([ticket-reply.tsx](src/lib/email/templates/ticket-reply.tsx) — localized for all 5 locales).
  • **Email-on-new-ticket → all platform admins** ([ticket-created.tsx](src/lib/email/templates/ticket-created.tsx)). Closes the previous loop where bug reports could sit unread for hours.
  • **Rate-limited submissions:** 10/hour per user_id via in-memory sliding window per CLAUDE.md §5.11. UI distinguishes the rate-limit toast from generic errors so the user knows to back off.
  • **Admin queue triage:** sort by `Recently updated | Newest | Priority` with asc/desc; ⏳ Stale badge for open tickets >3 days untouched + a `?stale=1` filter; open-ticket count chip on the sidebar `Tickets` link (test orgs excluded so the demo doesn't inflate the count). `adminUpdateTicket` and `adminAddTicketNote` now bump `updated_at` so the new sort actually reflects admin activity.
  • 14 i18n keys × 5 locales for the user-facing TicketButton + new support pages.

Changed

Schedule — customer feedback fixes
  • **24-hour time pickers across the app.** Native `<input type="time">` was rendering AM/PM for `lang="en"` users on Chrome/Edge (it falls back to `en-US`). New `localeToBcp47()` helper maps to Swiss-anchored BCP-47 tags: `en-GB / de-CH / fr-CH / it-CH / pt-PT`, all of which give 24-hour. One-line change in `<html lang>` covers every time input on every page.
  • **Calendar view now shows late-evening jobs.** Two bugs in [calendar-view.tsx](src/components/schedule/calendar-view.tsx) `slotMaxTime` calc: (1) `endHours` already rounded up partial hours, then `maxHour` added another `+1` — double-counting bumped the cap one slot too high; (2) clamp at `Math.min(23, …)` cut off the last 30 min of any 23:00+ job. Now allows up to `"24:00:00"` (FullCalendar's true end-of-day) and corrects the off-by-one. Default no-jobs range bumped 22:00 → 23:00.
  • **`revalidatePath` after every job mutation.** `actions/jobs.ts` previously had zero revalidation — newly created jobs didn't appear on `/schedule` until manual reload. Added `revalidateJobSurfaces()` helper called at the success boundary of all 15 mutations (createJob, updateJobStatus, cancelJob, deleteJob, rescheduleJob, reassignJob, unassignJob, updateJob, duplicateJob, bulkCancelJobs, setJobUrgent, reportRunningLate, toggleChecklistItem, cancelSeries, createRecurringJobs).

Changed

Schedule — first-run welcome
  • **Empty schedule no longer dumps users on a calendar grid with nothing on it.** New [empty-schedule-welcome.tsx](src/components/schedule/empty-schedule-welcome.tsx) shows three numbered onboarding steps when an org has zero clients AND zero jobs: (1) Add your first client (active CTA), (2) Schedule a job (pending preview), (3) Add staff (optional secondary). Card disappears as soon as either count goes positive — the user graduates to the regular empty-calendar state with the existing `+` CTA. Defensive fallback for team_leaders shows a quiet placeholder, not CTAs they can't act on. 14 i18n keys × 5 locales.

Changed

Clients — audit-driven UX + reliability pass
  • **`createClient` now redirects to the client detail page** instead of bouncing back to the list. The detail page already has the "Schedule a job" CTA, so the welcome flow's promise of "scheduling your first job within 5 minutes" actually closes.
  • **Optional address auto-creates a default property.** `createClientSchema` accepts `addressLine1` / `city` / `postalCode`; the sheet has a collapsible "Add address" section (auto-expanded when entered from the empty-state CTA). When filled, the action also INSERTs a `properties` row at the same address. Most residential clients = 1 home = 1 property; the previous 5-click flow is now 1.
  • **Email normalized at validator boundary.** Zod transform lower-cases + trims, so a search for `joao@example.com` matches a row created as `Joao@Example.COM`. Mirrors the team-invite normalization.
  • **Trash + restore.** New `restoreClient` action clears `deleted_at` (manager-only, audited). The clients page accepts `?status=deleted` to flip the soft-delete filter and show only trashed rows; `ClientRowActions` switches to a primary "Restore" button there. Accidental deletes are no longer SQL-only to recover.
  • **Optimistic concurrency on `updateClient`.** Optional `expectedUpdatedAt` parameter — when provided, the UPDATE adds `.eq('updated_at', expectedUpdatedAt)`; 0 rows matched returns `'stale_version'`. Returns the new `updatedAt` so callers can chain. UI wiring deferred — action ready.
  • **20 unit tests** at [tests/unit/lib/clients-actions.test.ts](tests/unit/lib/clients-actions.test.ts) covering: role gates (cleaner / team_leader rejected), validation, soft-lock, happy paths, address auto-property, property-failure-doesn't-roll-back-client, email lowercasing, OCC stale_version detection, restore not_found vs success, `setClientActive` activate/deactivate audit label correctness, `upsertClientAccessInfo` field-set/cleared metadata accounting.

Changed

Team — direct-add staff (no invite email required)
  • **`addMemberDirect` server action** lets managers create a fully-fledged member without going through the invite flow. The new user has a real `auth.users` + `user_profiles` row from the start, but never receives an email and never needs to log in for the manager to operate on their behalf — assign jobs, log time via `createManualEntry`, file PTO via `submitTimeOffOnBehalfOf`. Same RBAC + soft-lock + tier-feature + race-safe seat-cap claim as `inviteMember`. Cleaner can later self-onboard via the password-reset flow on the same email; user_id is stable, no data migration.
  • **UI: segmented toggle on the existing invite form** — "Add directly" (default) vs "Invite by email". Direct mode collects first/last name + optional phone since those normally come at invite-acceptance time. 11 i18n keys × 5 locales.
  • 10 unit tests covering: invalid input, role gates (cleaner + team_leader rejected), in-org duplicate detection, cross-org duplicate via Admin API error, generic Admin API failure → `create_failed`, profile-insert failure rolls back the orphan auth user, happy path, audit log, email lower-cased before insert.

Changed

Staff — audit-driven UX + reliability pass
  • **Bulk export consolidated into one download.** The staff list previously fired N staggered `/api/staff/[id]/export` downloads in a client loop — browsers throttle/block bulk downloads past ~10 simultaneous triggers, so large selections silently dropped exports. New [POST /api/staff/export-bulk](src/app/api/staff/export-bulk/route.ts) bundles every selected member's JSON snapshot into a single file (cap 200 ids/request); per-member auth still enforced by `exportStaffData`, failed members recorded under `errors` instead of aborting the batch. The list now POSTs once → one download, with a failure toast (`staff.bulk.exportFailed`) and a member-count success message.
  • **Org currency default aligned to EUR.** The staff detail page hard-coded `'CHF'` as the pay-rate fallback when an org had no `default_currency`; corrected to `'EUR'` per the documented org default (CLAUDE.md §5.15).
  • **Timezone-correct date handling on the staff detail page.** PTO active/upcoming windows, doc-expiry cutoffs, and the "today" boundary now compute via `getUserTimezone()` + `localDateISO()` instead of UTC `toISOString().slice(0,10)`, so a member's leave/expiry status no longer flips a day early/late for non-UTC orgs (landmine #7). Duration counts ("N days remaining") deliberately left as-is.
  • **Pagination clamp.** The staff list rendered a blank table when a filter shrank the result set below the current page offset; `effectivePage` now clamps to `[1, totalPages]` so the last valid page is shown instead.
  • **Query dedupe.** Removed a redundant `user_profiles` fetch on the detail page — reassign candidates and the create-job member picker now share one parsed result.

Changed

Staff — motion + a11y polish
  • Staff cards gain entrance animation (`animate-in fade-in slide-in-from-bottom-1`) and press feedback (`active:scale-[0.99]`), table rows gain `active:bg-muted/50`, all gated by `motion-reduce:*`.
  • The bulk-action bar is now an `aria-live="polite"` `role="status"` region so screen readers announce selection-count changes.

Security

Security
  • `addJobPhoto` now checks `job_assignments` for cleaner / team_leader roles before mutating `jobs.photos` JSONB. Closes the gap where storage RLS alone could be bypassed for the JSONB column.
  • `submitTimeOffRequest` validates attachment paths against the `{org_id}/time-off/{user_id}/...` shape per caller — prior check only matched the bucket prefix.
  • **Audit pass 2026-05-24 — security tightening.** Reduced platform-admin impersonation session TTL from 30 min → 5 min (`SESSION_DURATION_MS` in [admin-impersonation.ts](src/lib/actions/admin-impersonation.ts)); bounds the window where a revoked impersonation cookie can be used to navigate before `requireSession`'s DB liveness check kicks in. Added a startup warning when `IMPERSONATION_COOKIE_SECRET` is unset and the cookie signer falls back to `SUPABASE_SERVICE_ROLE_KEY` ([impersonation-cookie.ts](src/lib/auth/impersonation-cookie.ts)) — see new runbook [docs/ops/runbooks/impersonation-secret-rotation.md](docs/ops/runbooks/impersonation-secret-rotation.md) for setup. Replaced the fragile `last_sign_in_at − created_at < 60_000` first-signup heuristic with an explicit single-use `user_metadata.pending_first_signup` flag set in `registerOwner` and cleared in the auth callback's `after()` block before `setupOrgBilling`. Escalated audit-log INSERT failures from stdout-only `console.error` to Sentry via `captureError` with an `audit_log_failure: true` tag.
  • **Audit pass 2026-05-24 — Zod input validation on platform-admin actions.** New [src/lib/validators/admin.ts](src/lib/validators/admin.ts) with schemas for `adminRefundLastInvoice`, `adminGrantCredit`, `adminForceCancel`, `adminExtendGrace`, `adminChangeTier`, `adminRetryPayment`, `setOrgFeatureFlag`, and `setFeatureFlagRollout`. Each action's `input` is now Zod-parsed before any DB write; the granular error codes on `grantOrgQuotaBonus` (used by the admin UI for field-level error display) were kept inline. `searchAll()` gained a `z.string().trim().min(2).max(128)` bound to prevent unbounded LIKE patterns.

Changed

Notifications audit pass 6
  • **`invoice.paid` had no dedupe.** Stripe retries on 5xx re-ran the handler and re-fanned the bell ping — owners got duplicate "Payment received" notifications on every retry. Mirrored the `payment_failed_email_sent_at` gate with a new `stripe_invoices.payment_succeeded_notified_at` column; webhook now stamps after fan-out and short-circuits on retry. [route.ts](src/app/api/webhooks/stripe/route.ts), [migration](supabase/migrations/20260630000004_notifications_audit_pass_six.sql).
  • **`notifyManagers` fan-out lacked a paranoid post-filter.** The WHERE-clause filter on `org_id` is correct, but added belt-and-suspenders: UUID-shape guard on `args.orgId`, plus an assertion that every returned manager's `user_profiles.org_id === args.orgId` (Sentry captures impossible mismatches before insert).
  • **Proxy-backed SAMPLE_PAYLOAD on the prefs preview page didn't work.** ICU MessageFormat does a `hasOwnProperty`/`in` check before reading values; a Proxy with only a `get` trap reflects the target's own-keys, so any future catalog var not in the literal would render as `{varName}` literal — the exact failure the Proxy was meant to prevent. Replaced with a static dictionary + module-load sanity check. Adding a new catalog var now requires a one-line update here too. [notification-preferences-form.tsx](src/app/(dashboard)/settings/notifications/notification-preferences-form.tsx).
  • **Stripe webhook null-data junk.** If `invoice.id` AND `invoice.number` were both null (draft/preview Stripe objects) or `amount_paid` was 0, the bell got an "Invoice — paid CHF 0.00" notification. Both branches (paid + failed) now skip-on-bogus-data.
  • DB `notifications_payload_size_chk` relaxed to 4096 bytes so the app-side 2KB limit stays the operational gate and the DB check only catches truly runaway writes (jsonb `::text` canonicalization can expand certain escape-heavy payloads vs `JSON.stringify`).
  • `enqueueNotification` cross-org guard + prefs lookup now run in `Promise.all` — halves the latency the pass-5 guard added.
  • `client-events.ts` BroadcastChannel `addEventListener` is wrapped in try/catch (Safari < 15.4 / private mode can throw); failure mode degrades to poll-only.
  • Soft-deleted job link rewrite in `getNotifications` now uses the service client — a cleaner who was reassigned-away from a job no longer has the link stripped on rows pointing at jobs that still exist.
  • **Validator strict mode.** Spread (`...vars`) / variable-binding payloads now FAIL the typecheck (used to just warn). Inline literals required so the contract is statically checkable.
  • **New emission-sites.test.ts** sanity-checks the catalog shape + asserts each pass-5 type is present (4 tests, all green).
  • Migration 20260630000004 applied to remote + local.
  • Verification: validator clean (20 types, 28 sites), tsc clean, vitest 646 passed / 20 baseline-fail.

Changed

Notifications audit pass 5
  • **Cross-org silent dead-write in `enqueueNotification`.** Service-role inserts had no recipient-org assertion; a buggy caller passing a stale `orgId` wrote a row the new pass-4 RLS made unreadable. Now does a single `user_profiles` lookup and refuses inserts where `recipient.org_id !== args.orgId` (Sentry captures both the not-found and mismatch cases). [enqueue.ts](src/lib/notifications/enqueue.ts)
  • **`notifyManagers` zero-recipient TOCTOU.** Managers were fetched via the caller's RLS-aware client; if the caller's JWT org_id ≠ `args.orgId` the SELECT returned zero and the fan-out silently did nothing. Now uses the service client + explicit `args.orgId` filter — single source of truth. The `supabase` arg is kept for back-compat but ignored.
  • **Mark-read / dismiss silently failed after org switch.** `markNotificationsRead`, `dismissNotification`, `markAllNotificationsRead`, `dismissAllReadNotifications` all used the cookie client. Pass-4 cross-org RLS rejected updates on rows whose `org_id` ≠ current JWT — common right after `acceptInvitation` / `changeRole`. Now use the service client + explicit `recipient_user_id` filter; the BEFORE-UPDATE trigger keeps only `read_at` / `dismissed_at` mutable regardless of caller role. [notifications.ts](src/lib/actions/notifications.ts)
  • **Member-lifecycle silence.** Catalog had no `member.*` types at all. Added `member.invitation_accepted` (→ managers when invite consumed), `member.role_changed` (→ affected user), `member.removed` (→ managers). Wired into `acceptInvitation`, `changeRole`, `removeMember` in [team.ts](src/lib/actions/team.ts).
  • **`cancelTimeOffRequest` silence.** Cleaner cancels approved future time-off, manager schedules around a hole that just reopened. New `time_off.cancelled` catalog type emits to managers (and to the affected user if a manager cancelled on their behalf). [time-off.ts](src/lib/actions/time-off.ts).
  • **Billing category absent.** Stripe `invoice.payment_succeeded` and `invoice.payment_failed` sent email but no in-app bell. New `invoice.paid` + `invoice.payment_failed` catalog types fan-out to org owners from the webhook handlers; payment_failed reuses the existing `payment_failed_email_sent_at` dedupe so Stripe retries don't ping 4× per failure. [route.ts](src/app/api/webhooks/stripe/route.ts).
  • Validator now detects spread (`{ ...vars, foo }`) and variable-binding (`payload: someObj`) payloads and emits `UNVERIFIABLE_PAYLOAD` warnings instead of false-positive failures. Tightened key-extraction with NFKC-safe regex.
  • `getNotifications` short-circuits the soft-deleted-job filter SELECT when no notifications on the page point at `/jobs/{uuid}` (already guarded in the prior pass — confirmed).
  • **Dropped unused `notifications_org_created_idx`** (8 weeks, zero hits). Pass-4's `notifications_recipient_unread_idx` stays alongside the older `_active_idx` because the "all items" query doesn't filter on `read_at`.
  • aria-live region moved OUTSIDE the bell button so screen readers reliably announce unread-count changes even when focus is elsewhere.
  • **BroadcastChannel cross-tab sync** in [client-events.ts](src/lib/notifications/client-events.ts). Mark-read in tab A now wakes tab B's bell immediately instead of after the 30s poll. Pure browser API — no server cost, doesn't violate the no-Realtime rule.
  • Prefs page preview now uses a `Proxy`-backed SAMPLE_PAYLOAD with realistic example values per var and a generic "Example" fallback for future catalog vars; no more bare `—` or literal `{role}`.
  • **DB-level CHECK** `notifications_payload_size_chk` caps `octet_length(payload::text) <= 2048` — backstop in case a future writer skips the app-layer guard.
  • `prune_notifications()` now `SECURITY DEFINER SET search_path = public, pg_temp` — defense-in-depth against extension shadowing.
  • Two new tests in [tests/unit/lib/notifications/enqueue.test.ts](tests/unit/lib/notifications/enqueue.test.ts) cover the cross-org guard (mismatch + recipient-not-found).
  • Coverage test auto-includes the 6 new catalog types — now asserts 20 types, all green.
  • i18n for the new types in 5 locales via [scripts/add_audit_pass_five_i18n.py](scripts/add_audit_pass_five_i18n.py).
  • Migration [20260630000003_notifications_audit_pass_five.sql](supabase/migrations/20260630000003_notifications_audit_pass_five.sql) applied to remote + local.

Changed

Notification feature — full audit + prevention infrastructure (audit pass 4)
  • **[FEATURE_REGISTRY.md](../../../FEATURE_REGISTRY.md)** at the repo root. One row per feature → its canonical files (table, catalog, helpers, components, action sites, prefs surface). Before touching any file in a domain, read the row. Before adding a new file in a domain that already has a row, the registry forces an explicit extend-vs-replace decision before code.
  • **[scripts/validate-notification-payloads.ts](scripts/validate-notification-payloads.ts)** — parses `{var}` placeholders from every `notifications.types.*.{title,body}` template in all 5 locales, scans `src/` for `enqueueNotification` / `notifyManagers` call sites, and asserts payload keys ⊇ template vars. Runs as a pre-tsc step in `npm run typecheck`. Catches the entire class of "body renders `{cleanerName}` literal" bugs before merge.
  • **[tests/unit/lib/notifications/catalog-coverage.test.ts](tests/unit/lib/notifications/catalog-coverage.test.ts)** — one assertion per catalog type that grep finds an emission site. If anyone deletes an emission, the test fails. Currently 14/14 green.
  • **sparkd_app CLAUDE.md §12** updated — "Read the registry first" is rule 1 of the planning checklist, ahead of the existing "find the sibling".
  • Every `enqueueNotification` / `notifyManagers` call site now ships a payload matching the i18n template. Time-off `{range}` → `{startDate, endDate}`; expense `{}` → `{cleanerName, amount}` / `{amount}` (formatted via `formatCents` at emission time); `job.cancelled` / `job.rescheduled` now ship `{clientName, when}`. Validator confirms — 21 emission sites, all green.
  • `notifications.UPDATE` policy locked. New BEFORE-UPDATE trigger raises `notification_locked` on any column change other than `read_at`/`dismissed_at`. Recipients can no longer rewrite `type`, `payload`, `link_path`, etc. on their own rows.
  • Legacy `src/components/settings/notification-preferences-form.tsx` deleted (parallel surface with incompatible JSON-blob storage). `/settings/profile` now shows a single link card pointing at `/settings/notifications` (the only prefs surface). Orphan `settings.profile.notifications.*` i18n namespace removed across 5 locales; `NotificationPreferences` type + `DEFAULT_NOTIFICATION_PREFS` deleted; `updateNotificationPreferences` removed from `profile.ts` (the canonical one is in `notifications.ts`).
  • `reassignJob` (single-job path) now emits `job.reassigned_to` / `job.reassigned_away` — previously only `bulkReassignJobs` did, so manual single-job reassigns left both cleaners in the dark.
  • `cancelSeries` now fan-outs `job.cancelled` per assignee per cancelled instance.
  • `submitTimeOffOnBehalfOf` (non-auto path) now fan-outs `time_off.submitted` to other managers — previously only `submitTimeOffRequest` did, so on-behalf submissions were invisible to the queue.
  • `notifications` retention pruning: pg_cron `prune-notifications` runs daily at 03:23 UTC; deletes dismissed rows older than 90 days and read-but-not-dismissed older than 180 days.
  • Cross-org SELECT leak closed — RLS SELECT + UPDATE policies now also match `org_id::text = (auth.jwt() -> 'app_metadata' ->> 'org_id')`. A user removed from Org A and added to Org B can't read their Org A history any more.
  • `getNotifications` now runs the items fetch + unread count in `Promise.all` — halves perceived latency on every 30s bell poll.
  • `notifyManagers` batches: single bulk `select disabled_types where user_id IN (...)` then single bulk `insert([...rows])` — 10 managers used to be 10 sequential prefs queries + 10 inserts.
  • Added partial index `notifications_recipient_unread_idx` on `(recipient_user_id, created_at DESC) WHERE dismissed_at IS NULL AND read_at IS NULL` — bell's hot path no longer falls back to a heap re-check.
  • Bell "Mark all read" now calls `markAllNotificationsRead` (server-side, all unread) instead of marking only the visible 10. Label was misleading when `unread > 10`.
  • Same-tab event bus [src/lib/notifications/client-events.ts](src/lib/notifications/client-events.ts) wired into `useNotificationPoll`. Mark-read / dismiss in either the bell or inbox now refetches the other instance immediately — no more 30s stale-badge window.
  • Bell aria-live region announces unread-count changes. New `notifications.bell.ariaWithCount` ICU plural across 5 locales.
  • `sanitizeLinkPath` now `decodeURIComponent` + NFKC-normalizes before validating — `%2e%2e`, fullwidth `..`, and control chars are rejected. Catches the bypasses the previous string-only checks missed.
  • `enqueueNotification` caps payload at 2KB (`Buffer.byteLength(JSON.stringify(payload))`). Bell selects the payload column on every poll; a single 100KB row would balloon traffic for every recipient.
  • `createJob`, `bulkReassignJobs`, `cancelJob`, `deleteJob`, `cancelSeries`, `rescheduleJob`, `reassignJob` now all skip `session.userId` in their emission loops. No more self-pings on a manager's own action.
  • `getNotifications` strips `link_path` on rows pointing at soft-deleted jobs — batched per-page existence check via a single `select id from jobs where id in (...) and deleted_at is null`. Bell row still surfaces (the body has historical value) but the click is inert instead of 404ing.
  • Inbox filter chip back/forward sync — `useEffect` on `useSearchParams` re-syncs the chip when the user navigates back from `/notifications?filter=unread`.
  • Per-category icons in bell + inbox row: Briefcase (jobs), CalendarClock (reschedule), CalendarOff (time-off), Receipt (expense). Read rows render muted; unread render primary tint.
  • Bell popover now closes on row click even when `link_path` is null.
  • Dead i18n keys removed: `notifications.inbox.loadMore` (no "load more" was ever wired). `notifications.bell.markRead` repurposed as the bell mark-all-read label.
  • `prune_notifications()` retention helper exposed as RPC (service-role only) for ad-hoc ops.
  • Migration [20260630000002_notifications_audit_pass_four.sql](supabase/migrations/20260630000002_notifications_audit_pass_four.sql) applied to remote + local.
  • i18n via [scripts/fix_notification_prefs_i18n.py](scripts/fix_notification_prefs_i18n.py), [scripts/fix_bell_i18n_keys.py](scripts/fix_bell_i18n_keys.py), [scripts/fix_dotted_i18n_keys.py](scripts/fix_dotted_i18n_keys.py) (auxiliary cleanup from the prior pass).

Changed

Audit-pass-three fixes (post-Wk-8 audit)
  • **Cross-tenant leak in the Wk 8 aging view.** [`invoices_aging`](supabase/migrations/20260629000008_invoices_aging_view.sql) was created without `security_invoker = true`, so the view ran as the definer and RLS on the underlying `invoices` table was NOT inherited — any authenticated user could read every org's invoices. Fixed via `ALTER VIEW ... SET (security_invoker = true)` in the new migration.
  • **Invoice immutability was a blocklist, not the spec's allowlist.** The Wk 7 trigger only blocked seven money columns, so `invoice_number`, `due_date`, `issue_date`, `notes`, `customer_notes` etc. could be silently rewritten on a sent / paid / void invoice — audit trail broken. Trigger rewritten as a jsonb-based allowlist of eight legitimately-mutable columns (status, paid_at, payment_method, reminders_sent_at, pdf_snapshot_path, pdf_snapshot_at, sent_at, updated_at).
  • **Invoice-reminder cron sent duplicate emails on stamp-update failure.** The Wk 4 cron sent the email *before* appending to `reminders_sent_at` via a JS-side array — if the UPDATE failed, the next cron tick re-sent the same reminder. Also: no `deleted_at IS NULL` filter, no recheck-status-at-send, and read-modify-write could lose interleaved appends. Replaced with new `stamp_invoice_reminder(p_invoice_id, p_org_id)` RPC that does atomic `array_append` gated on `status IN ('sent','past_due') AND deleted_at IS NULL`. Cron now stamps first, sends second; if status changed mid-tick the stamp returns null and the send is skipped.
  • **Bulk-reassign assignment swap was non-atomic.** Insert-toUser + delete-fromUser were two REST calls — a process crash between them left jobs with both users (and potentially two leads). Replaced with new `reassign_job_assignment(p_job_id, p_org_id, p_from_user_id, p_to_user_id)` RPC that does insert + delete + optional helper→lead promote in a single function call. Also fixed: the "target already on job" branch was counted as `skipped_no_change` even when it actually removed the source and promoted the target — now correctly counted as `moved`.
  • **Reschedule-request approval reconstructed UTC instant using the approver's tz.** Cleaner in Zürich submitting wall-clock 14:00 + manager approving from NYC moved the job to 14:00 NYC (= 20:00 Zürich), 6h off. Added `proposed_timezone` column to `job_reschedule_requests`; submit captures the cleaner's tz, approval uses it to reconstruct the correct UTC instant. Legacy rows fall back to the approver's tz (matches prior behaviour).
  • **VAT summary quarter bounds used UTC, not org-local.** CH invoice sent 2026-04-01 00:30 CEST (= 03-31 22:30 UTC) was filed in Q1 by UTC but counted as Q2 by the accountant. `getVatSummary` now converts quarter start/end via the user's tz before querying `sent_at`.
  • **`<VatExportButton>` default quarter used UTC.** Jan 1 00:30 in Zürich seeded Q4 of the previous year instead of Q1. Switched to `getFullYear()` / `getMonth()` (browser-local).
  • **`ContactDialog` edit form showed stale data after switching targets.** `useState` initialiser only runs once — opening Edit on contact B after editing A previously showed A's data, and saving overwrote B with A's content. Replaced with a `useEffect` keyed on `editing?.id` that re-seeds when the target changes.
  • **Per-job price input was hardcoded to `CHF`** in `create-job-sheet.tsx`. Non-CHF orgs saw currency-mismatched line items flowing into invoices. The sheet now takes a `defaultCurrency` prop (defaulted to `CHF` for back-compat); the invoices and jobs pages pass the org's `default_currency`.
  • **Aging tile click-through landed on unfiltered invoices list.** The `<InvoicesAgingTile>` linked to `/invoices?status=overdue` but the invoices page never read `searchParams.status`. Wired through an `initialStatus` prop on `<InvoicesClient>`.
  • **VAT-rate Select couldn't switch into Custom mode.** When the current rate was canonical (e.g. 8.1%), picking "Custom…" early-returned without flipping state, so the Select reverted and the free-form input never appeared. Added a `customMode` state flag that forces the Custom code-path independent of whether the rate is canonical.
  • **Client-billing-tab error toasts rendered raw snake_case codes.** `try { t(key) } catch { fallback }` is dead because `next-intl` logs missing keys instead of throwing. Plus the delete-failed path showed the confirm-prompt text as the error toast. Replaced with the documented §5.1 whitelist pattern.
  • **Several mobile / a11y polish:** `BulkReassignDialog` `todayIso` now uses local clock not UTC; `<RescheduleRequestList>` formats with `timeZone: userTimezone`; credit-note submit button now disabled when the credit amount exceeds the invoice; `/notifications` page wrapper gained `pb-24 md:pb-0`; reschedule-request dialog blocks close while submit is in flight; properties-import sheet rejects > 2 MB uploads client-side; notification-inbox per-row dismiss uses a singular `dismissOne` aria-label instead of "Dismiss all read".
  • **Defense-in-depth `REVOKE`s** on `webhook_events` (insert/update/delete) and `notifications` (insert/delete) from `anon` / `authenticated` / `PUBLIC`. RLS-with-no-policy already blocks these today, but explicit REVOKEs prevent a future schema-defaults grant from silently opening a hole.
  • **Migration** [20260630000001_audit_pass_three_fixes.sql](supabase/migrations/20260630000001_audit_pass_three_fixes.sql) applied to remote (Management API) + local.
  • **i18n** via [scripts/add_audit_pass_three_i18n.py](scripts/add_audit_pass_three_i18n.py): `clients.import.properties.errors.fileTooLarge` and `notifications.inbox.dismissOne`, 5 locales.

Changed

VAT export + aging view (Wk 8 of 8-week plan)
  • **VAT/MwSt quarterly export.** New `getVatSummary({ year, quarter, currency? })` server action in [src/lib/actions/invoices.ts](src/lib/actions/invoices.ts) — groups invoices in `status IN ('sent','paid')` filtered by `sent_at` falling in the calendar quarter, buckets by effective tax rate (`invoice_items.tax_rate` overrides invoice-level so mixed-rate invoices contribute correctly). Returns `{ rows, totalNetCents, totalVatCents, totalInvoiceCount, periodStart, periodEnd, currency, year, quarter }`. Manager+ only. Audit log entry `invoices.vat_summary_exported` for the operator's accountant-handoff trail.
  • **`<VatExportButton>` UI** at [src/components/invoices/vat-export-button.tsx](src/components/invoices/vat-export-button.tsx). Popover next to the existing CSV export on `/invoices`: Year (last 3 + current + next) × Quarter (Q1–Q4). Click → downloads `sparkd-vat-summary-YYYY-Q.csv` with UTF-8 BOM (Excel + de-CH locale opens cleanly). Columns: `tax_rate_percent, net_amount, vat_amount, invoice_count, currency` + TOTAL row. Toasts on Forbidden / invalid_input / fetch_failed / empty quarter.
  • **Aging report as a SQL view.** Migration [20260629000008_invoices_aging_view.sql](supabase/migrations/20260629000008_invoices_aging_view.sql) — view `invoices_aging` projects `org_id, invoice_id, currency, total_cents, due_date, status, bucket, days_overdue` with buckets `0_30 | 31_60 | 61_90 | 90_plus | not_overdue | paid | void`. Single source of truth for the dashboard tile, the existing `?status=overdue` filter, and any future reports. RLS inherits from the underlying `invoices` table.
  • **`getAgingSummary()` server action** aggregates the 4 overdue buckets from the view, scoped to the caller's org. Manager+ only.
  • **Analytics dashboard tile** at [src/components/analytics/invoices-aging-tile.tsx](src/components/analytics/invoices-aging-tile.tsx). Stacked horizontal bar + 4-column totals grid coloured amber → orange → red → dark red. Self-hides when there are zero overdue invoices. Click-through to `/invoices?status=overdue`.
  • **i18n: 30+ keys × 5 locales** via [scripts/add_vat_export_and_aging_i18n.py](scripts/add_vat_export_and_aging_i18n.py) — `invoices.vatExport.*` (popover copy + 4 error codes + ICU-plural success toast) and `analytics.aging.*` (tile copy + bucket labels + ICU-plural invoice counts).
  • **15 unit tests** in [tests/unit/lib/invoices/vat-summary-bounds.test.ts](tests/unit/lib/invoices/vat-summary-bounds.test.ts) — pin the schema bounds (year ∈ 2020..currentYear+1, quarter ∈ 1..4 integer, currency length=3) + re-implement `quarterBounds` to catch drift in the period-start / period-end math (including leap-year Q1 2024).
  • **Item #6 (org-settings VAT dropdown) deferred.** Re-reading the org settings page turned up no tax-rate input to swap — it only has name + weekly-hours + logo. The spec assumption was incorrect; the default-tax-rate column exists on `organizations` but isn't surfaced in the UI today. Will roll into a future settings expansion rather than a one-line dropdown swap.
  • Spec: [tasks/active/vat-export-and-aging.md](tasks/active/vat-export-and-aging.md). Updated [FEATURES.md](FEATURES.md) under Invoicing (new "VAT/MwSt quarterly export" + "Aging report" subsections).

Changed

Bulk reassign + money-handling polish (Wk 7 of 8-week plan)
  • **Bulk reassign every job from one cleaner to another over a date range.** New [`<BulkReassignDialog>`](src/components/team/bulk-reassign-dialog.tsx) mounted on the staff detail page next to the actions menu. Quick-range chips (today / this week / next 30 days) + custom date inputs. Role preservation: if the source was the `lead`, the target inherits lead. If the target was already a `helper`, they get promoted. Cleaner-block-aware — blocked dates report `skipped_blocked` in the result modal. 90-day server-side range cap. Idempotency via per-submit dedupe key (5-min process-local cache) — Vercel timeout-retries don't double-move.
  • **Second cross-actor notification flow on the Wk-1 system.** Each moved job fires `job.reassigned_to` to the new assignee and `job.reassigned_away` to the old one. Both link to `/jobs/{id}`.
  • **`bulkReassignJobs` server action** in [src/lib/actions/jobs.ts](src/lib/actions/jobs.ts). team_leader+ role gate, soft-lock + impersonation guard, bulk-fetches client names + existing target-user assignments to avoid N+1, single audit-log entry per run with summary counts (no PII).
  • **TWINT added to the payment-method enum.** Major Swiss consumer-to-SMB payment rail, missing entirely until now. Available on the manual mark-paid flow + future Resend webhook + Stripe reconciliation paths. DB CHECK widened in the migration; Zod enum + UI radio buttons in `<InvoiceDetailClient>` updated.
  • **Canonical VAT-rate dropdown on the create-invoice sheet.** Free-form number input replaced with a select of `[0%, 2.6%, 3.8%, 7.7% (legacy), 8.1%]` for CH, plus a "Custom…" option that reveals the legacy free-form input for cross-border or exempt cases. New catalog at [src/lib/invoices/vat-rates.ts](src/lib/invoices/vat-rates.ts) with helpers `getVatRatesForCountry`, `isCanonicalVatRate`, `getDefaultVatRateForCountry` for future per-client country resolution. DB column stays `NUMERIC(5,4)` — UX-only change, all historical rates continue to work via "Custom".
  • **DB-layer invoice immutability guards.** New triggers in the migration prevent UPDATE on `invoice_items` and UPDATE of monetary columns on `invoices` (`subtotal_cents`, `tax_cents`, `total_cents`, `tax_rate`, `discount_cents`, `currency`, `client_id`) when the parent invoice's `status != 'draft'`. Legitimate post-send mutations (status flips, paid_at, payment_method, reminders_sent_at, PDF snapshot paths, sent_at, updated_at) stay allowed. Postgres P0001 errors mapped to a stable `invoice_locked` ActionResult error in `updateLineItem` / `deleteLineItem` / `updateInvoice`; UI surfaces "This invoice is sent — issue a credit note instead."
  • **Migration** [20260629000007_twint_and_invoice_locking.sql](supabase/migrations/20260629000007_twint_and_invoice_locking.sql) applied to remote DB.
  • **45+ i18n keys × 5 locales** via [scripts/add_bulk_reassign_and_money_polish_i18n.py](scripts/add_bulk_reassign_and_money_polish_i18n.py): `team.bulkReassign.*` (dialog + 9 error codes with ICU plurals), `invoices.paymentMethod.twint`, `invoices.create.taxRateCustom`, `invoices.errors.invoiceLocked`.
  • **30 new unit tests**:
  • Item #10 (VAT dropdown on org settings) deferred — same UX pattern as the create-invoice sheet, lower priority, picks up as a small follow-up.
  • Spec: [tasks/active/bulk-reassign-and-money-polish.md](tasks/active/bulk-reassign-and-money-polish.md). Updated [FEATURES.md](FEATURES.md) under Staff (bulk reassign) and Invoicing (payment-method + VAT-rate + immutability sections).

Changed

Notes split + property CSV import (Wk 6 of 8-week plan)
  • **Customer-visible vs internal notes on jobs.** New `jobs.customer_notes` column alongside the existing internal `jobs.notes`. Both job sheets (create + edit) now show two parallel textareas with 🔒 / 👁 icons + helper text clarifying the audience. Default is internal — a casual note like "client is fussy" stays private unless the operator explicitly drops it into the customer-visible field. Render: the job detail page surfaces the customer-visible note with a "Visible to customer" badge so the cleaner can cross-check what their customer will see; [cleaner-job-card.tsx](src/components/sparkd/cleaner-job-card.tsx) on mobile renders both with the same treatment.
  • **Customer-visible flag on client notes.** New `client_notes.is_customer_visible` boolean (default false). The Notes-tab composer + edit dialog gain a "Customer can see this" checkbox; each row carries a 🔒 / 👁 icon distinguishing internal from customer-visible. Editing a note doesn't auto-clear the flag — the action only touches `is_customer_visible` when the caller explicitly passes it.
  • **No data backfill.** Existing `jobs.notes` + `client_notes` rows stay internal (the safest interpretation). Customer-visible reads have no surface yet — the Wk-6 work is preparatory so the data model is right *before* invoice PDFs, reminder emails, or the future customer portal start surfacing notes.
  • **Property CSV import.** New [`<PropertiesImportSheet>`](src/components/clients/properties-import-sheet.tsx) on the clients list page next to the existing client import. Companion CSV template at [src/lib/import/properties-template.ts](src/lib/import/properties-template.ts) with columns `client_email`, `label`, `address_line1`, `address_line2`, `city`, `postal_code`, `country`, `key_code`, `access_notes`. Properties join to existing clients by `client_email` (case-insensitive, scoped to the caller's org); rows whose email matches no existing client are reported as `client_not_found` and skipped without blocking siblings. Per-row Zod validation; same 1000-row cap as the clients importer. Geocoding (lat/lng) is async / out-of-band — properties insert with null coordinates for backfill later.
  • **Migration** [20260629000006_notes_split.sql](supabase/migrations/20260629000006_notes_split.sql) applied to remote DB. Includes a partial index `client_notes (client_id, created_at DESC) WHERE is_customer_visible = true AND deleted_at IS NULL` so the future customer-portal query is fast without an extra migration.
  • **40+ i18n keys × 5 locales** via [scripts/add_notes_split_and_property_import_i18n.py](scripts/add_notes_split_and_property_import_i18n.py) — `jobs.form.notes{Internal,Customer}{Label,Help,Placeholder}`, `jobs.detailPage.notes{Internal,Customer,CustomerBadge}`, `clients.notes.{customerVisibleToggle,customerVisibleBadgeTitle,internalBadgeTitle}`, `clients.import.properties.*` (with ICU plurals on counts).
  • **13 new unit tests**:
  • Spec: [tasks/active/notes-split-and-property-import.md](tasks/active/notes-split-and-property-import.md). Updated [FEATURES.md](FEATURES.md) under the Schedule and Clients sections.

Changed

Documentation — FEATURES.md catalog
  • Added [FEATURES.md](FEATURES.md) — a plain-English catalog of every feature shipped in sparkd_app. Organized by domain (auth, schedule, jobs, cleaner mobile, time tracking, time off, expenses, clients, staff, equipment, invoicing, subscriptions, notifications, support, analytics, settings, i18n, PWA, integrations, cron jobs, platform admin, security). Includes an honest "what's NOT in the app yet" section listing deferred features with rationale.
  • Audience is sales / support / onboarding / sanity-check on what's actually shipped. Engineering details stay in [CLAUDE.md](CLAUDE.md); time-ordered diffs stay here in the changelog.
  • **Standing rule going forward**: every feature commit updates both this changelog AND `FEATURES.md`. Same diff. If a feature lands without updating both, the commit is incomplete.

Changed

Cleaner reschedule request (Wk 5 of 8-week plan)
  • **Cleaners can ask their manager to move a job** without texting them first. Subtle "Request reschedule" link just above the primary CTA on [cleaner-job-card.tsx](src/components/sparkd/cleaner-job-card.tsx) — opens [RescheduleRequestDialog](src/components/sparkd/reschedule-request-dialog.tsx), pre-seeded with the current job's date + times in the user's tz. Date + start + end + optional cleaner note. Same overnight / end-before-start guard as the create-job sheet, mirrored client-side for snappy feedback; server enforces.
  • **Manager-side pending inbox** at `/team/reschedule-requests` ([page.tsx](src/app/(dashboard)/team/reschedule-requests/page.tsx) + [list component](src/components/team/reschedule-request-list.tsx)). Each row shows the cleaner name, client, current scheduled time, proposed time, cleaner note. Inline Approve / Reject buttons each open a tiny confirm with an optional decision-note textarea.
  • **First cross-actor notification flow on the Wk-1 system.** Submit enqueues `reschedule.requested` to every manager+ in the org with a deep link to `/team/reschedule-requests?focus={id}` (list page scrolls + rings the matching row). Approve / reject enqueue `reschedule.approved` / `reschedule.rejected` to the cleaner. All three catalog types were locked in the Wk-1 spec specifically for this surface, so no notification-system changes needed.
  • **Approve delegates to the existing `rescheduleJob` action** — preserves cleaner-block, conflict-warning, calendar-sync, and audit-log logic in one place. If `rescheduleJob` returns an error (cleaner blocked, etc.), the request status is NOT flipped — manager can fix the underlying block and re-approve, or reject the request entirely. Decision UI maps `cleaner_blocked`, `already_decided`, `trial_expired`, `impersonation_read_only`, `Forbidden`, `not_found` to localised toasts.
  • **`job_reschedule_requests` table** with status enum (pending/approved/rejected via CHECK), partial unique index `(job_id) WHERE status='pending'` (at most one pending per job — 23505 maps to `request_already_pending`), org-scoped RLS where cleaners see only their own rows and manager+ see all org rows, updated_at trigger.
  • **New `/team/reschedule-requests` route** added to `ROUTE_PERMISSIONS` at `manager` minimum role. Cleaners are 403-redirected by proxy.
  • **Migration** [20260629000005_job_reschedule_requests.sql](supabase/migrations/20260629000005_job_reschedule_requests.sql) applied to remote DB.
  • **50+ i18n keys × 5 locales** via [scripts/add_cleaner_reschedule_request_i18n.py](scripts/add_cleaner_reschedule_request_i18n.py) — cleaner-side dialog + error codes, manager-side list page + decision dialog + error codes. Notification type strings (`reschedule.requested`/`approved`/`rejected`) were already shipped in Wk-1.
  • **13 new unit tests** at [reschedule-request.test.ts](tests/unit/lib/validators/reschedule-request.test.ts): submit schema (valid + 8 invalid cases + cleanerNote normalisation + length cap), decide schema (4 cases). All pass.
  • **Note on Schedule #3 (drag-drop calendar reschedule):** already shipped pre-spec — [calendar-view.tsx](src/components/schedule/calendar-view.tsx) has `editable={canEdit}`, `eventDrop={handleReschedule}`, `eventResize={handleReschedule}`. Audit miss; spec narrowed to Schedule #6 only.
  • Spec: [tasks/active/cleaner-reschedule-request.md](tasks/active/cleaner-reschedule-request.md). Heavy mock test for the full action (Supabase + assignment check + RPC) deferred — validator + tsc cover the type-level risks; Sentry breadcrumbs in the action surface runtime issues.

Changed

Credit note UI + overdue invoice reminders (Wk 4 of 8-week plan)
  • **"Issue credit note" dialog on every sent/paid invoice** at [src/components/invoices/issue-credit-note-dialog.tsx](src/components/invoices/issue-credit-note-dialog.tsx). Seeds an editable items table from the parent invoice's line items, live total summary, warns when the credit exceeds the invoice (server still rejects via `credit_exceeds_invoice`). Wires up to the existing `createCreditNote` action and `create_credit_note` RPC — both have been live since Wk-2 schema work, just no UI surface until now. On success: toast + route to `/credit-notes/{id}`.
  • **Credit notes section on the invoice detail page** — new server-rendered [InvoiceCreditNotesSection](src/components/invoices/invoice-credit-notes-section.tsx) lists every CN linked to the invoice (newest first, with CN number, total, reason, link). Self-hides when empty. Backed by a new per-invoice query helper `listCreditNotesForInvoice` in [src/lib/actions/credit-notes.ts](src/lib/actions/credit-notes.ts).
  • **Status-gated action button** — "Issue credit note" hidden on draft (cannot credit a draft — delete instead) and void (terminal cancel needs no credit). Manager+ role enforced via existing server-action role gate.
  • **Per-org overdue reminder schedule** — new `organizations.invoice_reminder_schedule_days int[]` column with default `{14, 30, 45}` (DACH-friendly cadence). Owner-only editor at [src/components/settings/invoice-reminder-editor.tsx](src/components/settings/invoice-reminder-editor.tsx) — chip-style input (Enter/comma/space commits, Backspace pops). Max 5 chips. Empty array disables reminders for the org. Mounted on the billing settings page.
  • **Daily overdue-reminder cron** at [src/app/api/crons/invoice-reminders/route.ts](src/app/api/crons/invoice-reminders/route.ts) — fires at 09:00 UTC. For each org with reminders enabled, pulls overdue invoices (`status IN ('sent','past_due')` AND `due_date < today`), gates each by `today - due_date >= schedule[reminders_sent_at.length]`, sends one reminder per ripe invoice via the new template, appends `now()` to `invoices.reminders_sent_at` on success. Idempotent across re-runs in the same day — the length-check + interval-gate together prevent double-firing. Bulk-fetches `client_contacts` once per org to keep the 4-tier recipient resolution N=1, not N=invoices. Per-run summary `{ processed, sent, skipped_quota, skipped_no_recipient, errors }` logged for Vercel observability.
  • **Friendly reminder email template** at [src/lib/email/templates/invoice-reminder.tsx](src/lib/email/templates/invoice-reminder.tsx). Localised STRINGS for en/de/fr/it/pt with deliberately *neutral* tone — no "this is your 2nd reminder," no fees, no escalation language. The formal *Mahnung* workflow (Mahnstufe levels, Verzugszins, chargeable fees) is the future Invoicing #7 spec; this batch ships the polite-nudge layer only. No PDF attachment — body links to the operator-side `/invoices/{id}` page (no customer portal yet, so this is the surface that already carries the PDF download).
  • **`invoices.reminders_sent_at timestamptz[]`** column (default `{}`) — append-only timestamp array; length = number of reminders sent. Partial index `(org_id, due_date) WHERE status IN ('sent','past_due') AND due_date IS NOT NULL` keeps the daily-sweep index tiny (paid invoices stay out of it).
  • **Vercel cron registration** in [vercel.json](vercel.json) for `/api/crons/invoice-reminders` at `0 9 * * *`.
  • **Migration** [20260629000004_invoice_reminders.sql](supabase/migrations/20260629000004_invoice_reminders.sql) applied to remote DB. The DB CHECK uses `<op> ALL(array)` (not `EXISTS (SELECT …)`) because Postgres disallows subqueries in CHECK constraints.
  • **70+ i18n keys × 5 locales** via [scripts/add_credit_note_and_reminders_i18n.py](scripts/add_credit_note_and_reminders_i18n.py) — credit-note dialog copy, error mapping, section title, reminder settings copy with ICU plurals for chip/active-hint labels.
  • **10 new unit tests** at [tests/unit/lib/validators/reminder-schedule.test.ts](tests/unit/lib/validators/reminder-schedule.test.ts) covering: sorted pass-through, unsort transform, dedupe, empty array, max-5, out-of-range (low + high), non-integer, negative, missing field. All pass.
  • Spec: [tasks/active/credit-note-ui-and-overdue-reminders.md](tasks/active/credit-note-ui-and-overdue-reminders.md). The heavy cron mock test was deferred — the cron mixes 3 surfaces (Supabase + Resend + quota) across a nested loop; Sentry breadcrumbs + per-run summary log are the operator surface for runtime issues. Pick up if a first-cohort issue surfaces.

Changed

Per-job pricing + Resend webhook idempotency (Wk 3 of 8-week plan)
  • **`jobs.price_cents` + `jobs.is_quoted`** — new optional columns on the jobs table. When `price_cents` is set, it flows directly into the invoice line item the job generates. When null, the existing `client.default_hourly_rate_cents × duration_minutes / 60` fallback continues. `is_quoted=true` distinguishes "fixed quote, invoice as-is" from "estimated total, review before sending." DB `CHECK 0..10_000_000` (CHF 100k cap) mirrors the Zod bound.
  • **Job create + edit sheets surface the price field** — empty input clears price + quoted; entering a value unlocks the "Fixed quote" checkbox. Helper line under the input shifts based on state (`Will use client default rate × duration` / `Estimated total — editable when invoicing` / `Fixed quote — invoiced as-is`). Wired in [src/components/jobs/create-job-sheet.tsx](src/components/jobs/create-job-sheet.tsx) (new `<PriceField>` sub-component) and [edit-job-sheet.tsx](src/components/jobs/edit-job-sheet.tsx).
  • **Invoice line-item prefill respects per-job price** — [createInvoiceSheet](src/components/invoices/create-invoice-sheet.tsx) now reads `priceCents` from each selected job; falls back to `client.defaultHourlyRateCents × durationMinutes / 60` (rounded to the nearest cent) when null. Selecting 3 unbilled jobs no longer drops the operator on a screen of zeros.
  • **Resend webhook idempotency ledger** — new `webhook_events` table (UNIQUE `(provider, event_id)`, service-role-only). [`/api/webhooks/resend`](src/app/api/webhooks/resend/route.ts) inserts each Svix-signed event into the ledger before processing; redeliveries short-circuit on 23505 conflict and return 200, preventing a re-fired "delivered" event from stamping over a more-recent "opened" timestamp. Genuine DB errors return 500 so Svix retries within its 24h window.
  • **Missing `RESEND_WEBHOOK_SECRET` now returns 503** (was 500) — Resend retries cleanly once ops sets the env var. Spec §S5 amendment kept the hand-rolled HMAC verification (no `svix` package dep) since the existing implementation was already production-correct.
  • **Migration** [20260629000003_job_pricing_and_webhook_events.sql](supabase/migrations/20260629000003_job_pricing_and_webhook_events.sql) applied to remote DB.
  • **20 new i18n keys × 5 locales** for the price field (label, quoted checkbox, three helper variants, error code) via [scripts/add_per_job_pricing_i18n.py](scripts/add_per_job_pricing_i18n.py). The invoice email-status keys (delivered/opened/bounced/complained) were already in the dictionary — the existing `invoice-email-history.tsx` component now displays them with confidence that the webhook is feeding it.
  • **22 new unit tests**: [tests/unit/lib/validators/job-pricing.test.ts](tests/unit/lib/validators/job-pricing.test.ts) (12 — integer bound, sanity cap, isQuoted independence, defaults, both create + update); [tests/unit/app/resend-webhook.test.ts](tests/unit/app/resend-webhook.test.ts) (10 — bad signature → 401, missing headers → 400, missing secret → 503, duplicate svix-id → 200, non-conflict DB error → 500, delivered/bounced/complained column writes, opened first-write-wins filter, no-email_id ack). All pass.
  • Spec: [tasks/active/per-job-pricing-and-resend-webhook.md](tasks/active/per-job-pricing-and-resend-webhook.md). Item #10 (price chip on job rows) deferred — display polish without spec-blocking value.

Changed

Client billing identity + multiple contacts (Wk 2 of 8-week plan)
  • **Per-client billing identity.** New columns on `clients`: `legal_name`, `billing_address_line1/2`, `billing_postal_code`, `billing_city`, `billing_country` (ISO-3166-1 alpha-2, DB CHECK), `vat_id`, `billing_email`, `client_segment` (residential/airbnb/office/commercial/medical/other). All optional and additive — non-breaking for existing clients.
  • **`client_contacts` table** with role enum (primary/billing/onsite/emergency), `is_primary` partial-unique-index (at most one primary per non-deleted client), soft-delete, manager+ RLS for writes, org-scoped reads. New `set_client_primary_contact` RPC (`SECURITY DEFINER`, re-checks `org_id`) flips primary atomically — eliminates the TOCTOU window that would otherwise race the partial unique index.
  • **Country-aware VAT validation** at the Zod layer. CH UID (`CHE-123.456.789` ± `MWST`/`TVA`/`IVA`), DE (`DE\d{9}`), AT (`ATU\d{8}`), free-form length-bounded for other countries. Canonicalisation strips the suffix on save so the stored value is consistent.
  • **Invoice PDF "Bill To" block** now resolves via [src/lib/invoices/bill-to.ts](src/lib/invoices/bill-to.ts) — billing identity wins when `billing_address_line1 + (billing_postal_code OR billing_city)` is set; otherwise the existing service address renders as before. All-or-nothing — no half-rendered addresses. VAT is rendered only on the billing-block path. Applied to all 3 templates (classic / minimal / bold).
  • **Swiss QR-bill debtor block** uses the same resolver in `generateInvoicePdf` — when billing fields are complete the QR-bill carries the proper legal entity + billing address.
  • **`sendInvoiceEmail` 4-tier recipient resolution** ([src/lib/actions/invoices.ts](src/lib/actions/invoices.ts)): (1) `clients.billing_email` override, (2) most-recently-updated `client_contacts` with `role='billing'`, (3) `client_contacts` with `is_primary=true`, (4) `clients.email` fallback. Invoice routing now lands on the right person without changing the operator's primary contact.
  • **Client detail page** gains a "Billing" tab between Preferences and Notes — [src/components/clients/client-billing-tab.tsx](src/components/clients/client-billing-tab.tsx). Billing identity form (autosave on Save) plus contacts CRUD with set-primary toggle, role chips, and a confirm-on-delete soft-delete. Read-only for non-managers.
  • **60+ i18n keys × 5 locales** for billing fields, contacts table/dialog, segment enum, and country-specific VAT error codes (`invalid_vat_ch`, `invalid_vat_de`, `invalid_vat_at`, `invalid_country`, `invalid_billing_email`, `contact_email_or_phone_required`). Injected via [scripts/add_client_billing_identity_i18n.py](scripts/add_client_billing_identity_i18n.py).
  • **Migration** [20260629000002_client_billing_identity.sql](supabase/migrations/20260629000002_client_billing_identity.sql) applied to remote DB. No data backfill — existing `clients.email`/`phone` remain the bottom of the fallback chain rather than being synthesized into contact rows (decision in spec §5).
  • **28 new unit tests**: [billing-identity.test.ts](tests/unit/lib/validators/billing-identity.test.ts) (22) covers CH/DE/AT VAT regexes, MWST suffix stripping, country upper-casing, email normalisation, segment enum, contact email-or-phone refinement; [bill-to.test.ts](tests/unit/lib/invoices/bill-to.test.ts) (6) covers the all-or-nothing fallback rules. All pass.
  • Spec: [tasks/active/client-billing-identity-and-contacts.md](tasks/active/client-billing-identity-and-contacts.md). Items #10 (create-sheet billing section) and #11 (invoice create bill-to chip) deferred — operators fill billing from the detail tab, and email routing already lands on the right contact without the chip.

Changed

In-app notifications + badge
  • **New header notification stream bell** at [src/components/notifications/notification-bell.tsx](src/components/notifications/notification-bell.tsx). Popover surfaces the last 10 notifications with unread badge (caps at "99+"), per-row mark-read, and a "View all" link to the full inbox. Coexists with the legacy actionable-counts bell — cleaners now see the new one; managers see both (the legacy one continues to show pending TO/expense counts).
  • **30-second polling, focus-aware.** New [src/hooks/use-notification-poll.ts](src/hooks/use-notification-poll.ts) polls only when `document.visibilityState === 'visible'` and re-polls immediately on `visibilitychange` / `window.focus`. No Realtime channel — respects CLAUDE.md §9.
  • **`/notifications` inbox** at [src/app/(dashboard)/notifications](src/app/(dashboard)/notifications) — all/unread filter, mark-all-read, dismiss-read bulk actions, per-row dismiss.
  • **`/settings/notifications` preferences** with per-type toggles grouped by category (Jobs / Reschedule / Time-off / Expenses); manager-only types hidden from cleaners. Both routes added to `ROUTE_PERMISSIONS` at `cleaner` min role.
  • **Frozen 14-type catalog** at [src/lib/notifications/catalog.ts](src/lib/notifications/catalog.ts) covering job assignment/reschedule/cancellation, reschedule requests (manager ↔ cleaner), time-off submissions/decisions, and expense submissions/decisions. Adding a new type is a code-only change — no migration. The migration intentionally doesn't enum `notifications.type`.
  • **`enqueueNotification()` helper** at [src/lib/notifications/enqueue.ts](src/lib/notifications/enqueue.ts) — fire-and-forget, service-client write, honors per-user opt-out via `user_notification_preferences.disabled_types`, sanitises `link_path` (rejects no-leading-slash, `..` traversal, `:` protocols), escalates errors to Sentry with `notification_enqueue_failure: true`.
  • **`createJob` enqueues `job.assigned`** per newly-assigned cleaner on forward-scheduled jobs — the starter event proving the end-to-end loop. Other call sites (`reassignJob`, `rescheduleJob`, `cancelJob`, time-off, expenses, reschedule.requested) wire in their respective sprint weeks.
  • **Migration** [20260629000001_notifications.sql](supabase/migrations/20260629000001_notifications.sql) creates `notifications` (recipient-scoped RLS, INSERT reserved for service role) and `user_notification_preferences` (own-row read/write only). Applied to remote DB.
  • **65+ i18n keys × 5 locales** via [scripts/add_in_app_notifications_i18n.py](scripts/add_in_app_notifications_i18n.py) — bell, inbox, settings, category labels, 14 type title/body pairs.
  • **5 unit tests** at [tests/unit/lib/notifications/enqueue.test.ts](tests/unit/lib/notifications/enqueue.test.ts) — happy path, unknown-type rejection, disabled-type no-op, malformed-link-path sanitization, insert-error → Sentry escalation. All pass.
  • Spec: [tasks/active/in-app-notifications.md](tasks/active/in-app-notifications.md).

Changed

Docs
  • Added [docs/ops/open-todos.md](docs/ops/open-todos.md) — running register of remaining `TODO(ocr)`, `TODO(tier-on-signup)`, `TODO(notifications)`, `TODO(analytics)`, and the rescheduled-jobs scope gap. Each entry names the deferred decision and why it's safe to ship without resolving it.
  • Added [docs/ops/runbooks/backup-restore.md](docs/ops/runbooks/backup-restore.md) — Supabase clone-restore + PITR procedures, storage-bucket recovery notes, post-restore verification checklist, SLA targets (RPO 2 min / RTO 30 min for targeted SQL fix), quarterly test cadence.
  • Added [docs/ops/runbooks/impersonation-secret-rotation.md](docs/ops/runbooks/impersonation-secret-rotation.md) — initial setup of `IMPERSONATION_COOKIE_SECRET` (32 random bytes hex, set via `printf | vercel env add`), rotation ceremony, and the 5-minute session-TTL rationale.
  • Added [tasks/active/in-app-notifications.md](tasks/active/in-app-notifications.md) and [tasks/active/client-billing-identity-and-contacts.md](tasks/active/client-billing-identity-and-contacts.md) feature specs with §3 numbered scope contracts.

Deprecated

Deprecated
  • _None._

Removed

Removed
  • _None._

0.1.0

2026-04-30

Added

Added
  • Multi-tenant foundations: `organizations` / `user_profiles` / role hierarchy (owner > manager > team_leader > cleaner), JWT `app_metadata` claims via `set_jwt_claims()` trigger.
  • Auth flows: signup, login, accept-invite, forgot/reset-password, MFA/TOTP with AAL2 gate, OAuth-org-setup.
  • Schedule module: URL-driven view state, RPC-backed heatmap, day/week/month views, MonthDaySheet drill-down with iOS-style transitions, WeekSummaryView for managers, density tint on month grid, keyboard shortcuts, collapsible sidebar, cleaner-search sidebar, CleanerProfileSheet.
  • Jobs module: list/detail, recurring `job_series`, assignments with `lead`/`support` roles, checklist with optimistic sync RPC + pub/sub rollback, photo evidence, mobile cleaner UI (active-job-bar, sparkd/* components).
  • Clients & properties: KPI strip, tag chips, contact normalization, search threshold, type icons.
  • Time entries (clock-in/out + breaks), time-off requests with attachments + 90d pg_cron purge, time-off allowances, public holidays.
  • Expenses with receipt upload, in-memory rate limiting, manager review.
  • Invoicing: invoices, invoice items, credit notes, Swiss QR-bill PDFs (`@react-pdf/renderer` + pdfkit), per-org numbering.
  • Billing: Stripe subscriptions across Freelancer / Team / Business / Enterprise tiers, seat caps, soft-lock on trial expiry / cancellation, paused state, structured webhook logs, idempotency via `stripe_events`, payment-failed dedup.
  • Platform admin surface (`/admin/*`) with cross-org reads, impersonation reads, `admin_audit_log`.
  • Integrations: Google Calendar + QuickBooks OAuth (encrypted tokens via `INTEGRATION_ENCRYPTION_KEY`).
  • i18n across 5 locales (`en`, `de`, `fr`, `it`, `pt`) — single URL space, cookie/header detection.
  • Sentry v10 (production-only init), PostHog product analytics (consent-gated, EU host).
  • Cron endpoints: `cleanup-events`, `equipment-reminders`, `job-reminders`, `timesheet-digest`, `trial-expiry`.
  • Legal scaffolding: GDPR self-export, cookie consent banner, legal footer mounted under `(auth)` and `(dashboard)`.
  • Critical-path Playwright E2E + Vitest unit + axe a11y test stacks; Storybook for reusable UI.
  • Error boundaries on 12 dashboard list pages.
  • Admin i18n + team empty state.
  • Big-cleaning-co demo seed script (`scripts/seed_demo_big_co`).

Changed

Changed
  • Schedule visual pass: compact stat chips, view-aware date-nav strip, compact StaffView header, reduced FullCalendar padding, tightened week-view event tiles, hidden team filter in week/month views.
  • Jobs table visual pass: group rows by date, collapse time/duration column, table column rationalization on `/clients`.
  • PageHeader: tighter layout, icon chips on every dashboard page header.
  • Currency consistency pass and mobile schedule padding tweaks.
  • Schedule sort + filter use `history.replaceState` (no server refetch); View Transitions on day-view sort + filter.
  • Local Supabase `max_rows` bumped 1000 → 10000 to avoid losing recent dates on >1k-job orgs.

Fixed

Fixed
  • Job query window prevents >1k-job orgs from losing recent dates.
  • Staff-view grid + now-line render in user timezone.
  • View-aware loading skeleton (CalendarSkeleton branches on granularity).
  • Cancelled-bar contrast + heatmap RPC error feedback.
  • Schedule correctness fixes from audit (multiple).
  • Server-component-incompatible `onClick` removed from clients table links.
  • `tel:` link normalization on clients page.
  • Properties without geocoding now render `—` in the location column.
  • Numerous `seed_demo_big_co` runtime fixes (D1–D5, A1–A3, B1–B6, C1–C5, dry-run).

Security

Security
  • Row-level security on every domain table, with second policy layer for role-scoped reads (e.g., cleaners see only their assignments).
  • Storage buckets defined with size + MIME limits; private buckets accessed exclusively via service-client signed URLs (8h TTL).
  • Security headers added globally in `next.config.ts` (X-Frame-Options, HSTS preload, Permissions-Policy, Referrer-Policy).
  • AAL2 gate in `proxy.ts` enforces TOTP challenge for users with verified factors.
  • `requireWriteAccess` billing soft-lock blocks writes for trial-expired / canceled orgs while preserving reads.
  • Audit log on every privileged write (role change, invite, billing change, export, impersonation).
  • Cron endpoints protected by `Authorization: Bearer ${CRON_SECRET}`.
  • Stripe webhook idempotency via `stripe_events` UNIQUE constraint + per-invoice email dedup on `payment_failed`.
  • Job-photos assignment-check RLS migration; `job_assignments` index for hot-path lookups; `get_team_jobs` RPC.

Deprecated

Deprecated
  • _None tracked yet._

Removed

Removed
  • ActivityTicker realtime subscription (replaced by server-rendered strips for cost).