# Per-Entity Activity Timeline — Implementation Plan

**Spec:** docs/specs/2026-05-31-activity-timeline.md  ·  **Slug:** activity-timeline  ·  **Wave:** 12
**Depends on:** audit-compliance, customers-module, foundation-auth-rbac, invoices-core, projects-module

## Goal
Deliver an inline, user-facing chronological activity feed for invoices, projects, customers, and vendors. Each entity type gets its own dedicated activity-log table (mixing system events and manual notes), a shared `ActivityTimeline` React component, per-entity REST endpoints, and same-transaction writer helpers that other modules call when they mutate entity state. This is distinct from the compliance `tenant_audit_log` (owned by audit-compliance): the timeline is editable (notes soft-deletable within 15 minutes), entity-scoped, and visible to non-admin roles.

## Architecture
- **DB layer (`@zync/db`):** Four new tables — `invoice_activities`, `project_activities`, `customer_activities`, `vendor_activities` — each FK-cascaded to its parent entity (`invoices(id)`, `projects(id)`, `customers(id)`, `vendors(id)`) plus `tenant_id → tenants(id)` and nullable `actor_id → users(id)`. `vendors` is owned by spec 182 (`vendors-suppliers`), NOT in this task's depends_on; reference `vendors(id)` verbatim and treat the vendors table as a hard prerequisite at build time. Leads reuse `lead_activities` (owned by spec 22) — this plan does NOT create or alter it.
- **Writer helpers (`@zync/db`):** Exported `appendInvoiceActivity / appendProjectActivity / appendCustomerActivity / appendVendorActivity`. Each accepts a Drizzle transaction handle so callers in invoices-core, projects-module, customers-module, marketing-leads-pipeline insert the activity row in the SAME transaction as the state change (line 136 mandate). Consumes upstream `tenantQuery` scoping conventions.
- **API layer (Hono, `apps/zync-api`):** Per-entity GET (feed) / POST (note) / DELETE (soft-delete note) routes mounted under existing invoice/project/customer route groups. Uses upstream `authMiddleware`, `requirePermission`, `buildPaginated` shape conventions, and Zod validation (`require-zod-validation-in-routes`). Cursor pagination honors the documented `before=ISO8601` contract literally (NOT the opaque `encodeCursor/decodeCursor` helpers).
- **UI layer (`apps/zync-app`, React):** Shared `ActivityTimeline` component + `useActivityFeed` hook (react-query infinite). Rendered on invoice/project/customer/vendor detail pages. Consumes `@zync/ui` primitives (`Button`, `Textarea`, `Avatar`, `Spinner`, `EmptyState`) and i18n/RTL helpers (`useDirection`, `translations`).

## Tech Stack
- **Packages:** `@zync/db` (Drizzle schema + writer helpers), `@zync/types` (`ActivityEvent`, `ActorType`), `@zync/ui` (consumed, not extended).
- **Apps:** `apps/zync-api` (Hono routes), `apps/zync-app` (Vite+React component & hook).
- **Libraries:** Drizzle ORM, Zod, `@tanstack/react-query`.
- **Cloudflare bindings:** `DB` (Neon Postgres via Hyperdrive). No new bindings.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A | 1 (schema), 2 (types) | `packages/db/src/schema/activities.ts`, `packages/types/src/activity.ts` | Yes (1 ∥ 2) |
| B | 3 (writer helpers) | `packages/db/src/activities/*` | After A |
| C | 4 (serializer + feed query), 5 (invoice routes), 6 (project routes), 7 (customer routes), 8 (vendor routes) | `apps/zync-api/src/routes/*` | 5–8 ∥ after 4 |
| D | 9 (ActivityTimeline UI), 10 (hook), 11 (detail-page wiring) | `apps/zync-app/src/components/activity/*` | 9–10 ∥, 11 after |

## Tasks

### Task 1: Database schema — four activity tables
**Blocks:** 3, 4  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/activities.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
- Create: `packages/db/migrations/<timestamp>_activity_timeline.sql`
**Steps:**
- [ ] Define Drizzle table definitions matching the canonical DDL below (UUID PKs, UUID→UUID FKs, JSONB metadata, TIMESTAMPTZ, CHECK enums).
- [ ] Add `deleted_at TIMESTAMPTZ` to every table (required for soft-delete of notes; spec line 173).
- [ ] Add a per-table `CHECK` constraint on `event_type` using each table's enumerated event list (spelled out fully in the DDL below).
- [ ] Add `CHECK (actor_type IN ('user','system','customer'))` to all four.
- [ ] Reference `vendors(id)` verbatim — do NOT define a vendors table here.
- [ ] Export all four tables from the schema barrel.
- [ ] Emit the raw SQL migration.
**Schema / Interfaces:**
```sql
CREATE TABLE invoice_activities (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  invoice_id  UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  actor_id    UUID REFERENCES users(id),                       -- NULL for system events
  actor_type  TEXT NOT NULL DEFAULT 'user' CHECK (actor_type IN ('user','system','customer')),
  event_type  TEXT NOT NULL CHECK (event_type IN (
                'created','sent','approved','rejected','tax_issued','paid','voided',
                'credit_noted','note_added','email_sent','viewed_by_customer','payment_initiated')),
  metadata    JSONB NOT NULL DEFAULT '{}',
  note        TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at  TIMESTAMPTZ
);
CREATE INDEX idx_invoice_activities_invoice ON invoice_activities(invoice_id, created_at DESC);

CREATE TABLE project_activities (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  project_id  UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  actor_id    UUID REFERENCES users(id),
  actor_type  TEXT NOT NULL DEFAULT 'user' CHECK (actor_type IN ('user','system','customer')),
  event_type  TEXT NOT NULL CHECK (event_type IN (
                'created','status_changed','member_added','member_removed',
                'note_added','invoice_linked','time_logged')),
  metadata    JSONB NOT NULL DEFAULT '{}',
  note        TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at  TIMESTAMPTZ
);
CREATE INDEX idx_project_activities_project ON project_activities(project_id, created_at DESC);

CREATE TABLE customer_activities (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id),
  customer_id  UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  actor_id     UUID REFERENCES users(id),
  actor_type   TEXT NOT NULL DEFAULT 'user' CHECK (actor_type IN ('user','system','customer')),
  event_type   TEXT NOT NULL CHECK (event_type IN (
                 'created','updated','portal_user_invited','portal_user_activated',
                 'note_added','lead_converted','archived')),
  metadata     JSONB NOT NULL DEFAULT '{}',
  note         TEXT,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at   TIMESTAMPTZ
);
CREATE INDEX idx_customer_activities_customer ON customer_activities(customer_id, created_at DESC);

CREATE TABLE vendor_activities (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  vendor_id   UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,   -- vendors owned by spec 182
  actor_id    UUID REFERENCES users(id),
  actor_type  TEXT NOT NULL DEFAULT 'user' CHECK (actor_type IN ('user','system','customer')),
  event_type  TEXT NOT NULL CHECK (event_type IN (
                'created','updated','withholding_cert_uploaded',
                'withholding_cert_expiring','archived')),
  metadata    JSONB NOT NULL DEFAULT '{}',
  note        TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at  TIMESTAMPTZ
);
CREATE INDEX idx_vendor_activities_vendor ON vendor_activities(vendor_id, created_at DESC);
```
**Acceptance:**
- [ ] Migration applies cleanly against Neon; all four tables + indexes exist.
- [ ] Inserting an out-of-set `event_type` or `actor_type` raises a CHECK violation.
- [ ] FK CASCADE deletes activity rows when parent entity row is deleted.

### Task 2: Shared types — `ActivityEvent`, `ActorType`
**Blocks:** 4, 9, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/activity.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define `ActorType` and `ActivityEvent` exactly per the spec shape.
- [ ] Re-export from the `@zync/types` barrel.
**Schema / Interfaces:**
```ts
export type ActorType = 'user' | 'system' | 'customer'

export interface ActivityEvent {
  id: string
  actorId: string | null
  actorName: string | null      // resolved from users table; null for system
  actorType: ActorType
  eventType: string
  metadata: Record<string, unknown>   // event-type-specific (e.g. status_changed → { from, to })
  note: string | null
  createdAt: string             // ISO8601
}

export interface ActivityFeedResponse {
  events: ActivityEvent[]
  hasMore: boolean
}
```
**Acceptance:**
- [ ] `import { ActivityEvent, ActorType, ActivityFeedResponse } from '@zync/types'` typechecks.

### Task 3: Same-transaction writer helpers
**Blocks:** 5, 6, 7, 8  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/activities/writers.ts`
- Modify: `packages/db/src/index.ts` (export helpers)
**Steps:**
- [ ] Implement one helper per entity. Each accepts a transaction handle (`tx`) so the caller controls atomicity, and inserts a single activity row.
- [ ] For automated/system events, callers pass `actorId: null, actorType: 'system'`.
- [ ] Helpers do NOT open their own transaction — they reuse the caller's `tx` (line 136/208 mandate: must not be orphaned).
- [ ] Default `metadata` to `{}` when omitted.
**Schema / Interfaces:**
```ts
import type { ActorType } from '@zync/types'
import type { DbTx } from '../client'   // Drizzle transaction handle type

interface AppendActivityBase {
  tenantId: string
  actorId: string | null
  actorType?: ActorType            // default 'user'
  eventType: string
  metadata?: Record<string, unknown>
  note?: string | null
}

export function appendInvoiceActivity(
  tx: DbTx, args: AppendActivityBase & { invoiceId: string }
): Promise<{ id: string }>

export function appendProjectActivity(
  tx: DbTx, args: AppendActivityBase & { projectId: string }
): Promise<{ id: string }>

export function appendCustomerActivity(
  tx: DbTx, args: AppendActivityBase & { customerId: string }
): Promise<{ id: string }>

export function appendVendorActivity(
  tx: DbTx, args: AppendActivityBase & { vendorId: string }
): Promise<{ id: string }>
```
**Acceptance:**
- [ ] Calling a helper inside a tx that later rolls back leaves no activity row (atomicity verified).
- [ ] `eventType` outside the table's CHECK set rejects at insert time.

### Task 4: Feed query + serializer (shared)
**Blocks:** 5, 6, 7, 8  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/lib/activity-feed.ts`
**Steps:**
- [ ] Implement a generic `loadActivityFeed` that, given a table + scope column + entity id + optional `before` cursor + tenant id, returns the newest-first page.
- [ ] Query: `WHERE <scope>_id = :id AND tenant_id = :tenantId AND deleted_at IS NULL AND (:before IS NULL OR created_at < :before) ORDER BY created_at DESC LIMIT 21`. Fetch `limit+1` rows; `hasMore = rows.length > limit`; trim to `limit`.
- [ ] LEFT JOIN `users` to resolve `actorName` (NULL when `actor_id` is NULL → system).
- [ ] Implement `serializeActivity(row): ActivityEvent` (camelCase, `createdAt` as ISO8601 string).
- [ ] Default `limit` to 20; clamp to a max of 20 per the spec.
**Schema / Interfaces:**
```ts
import type { ActivityEvent, ActivityFeedResponse } from '@zync/types'

export async function loadActivityFeed(opts: {
  table: 'invoice_activities' | 'project_activities' | 'customer_activities' | 'vendor_activities'
  scopeColumn: 'invoice_id' | 'project_id' | 'customer_id' | 'vendor_id'
  entityId: string
  tenantId: string
  before?: string   // ISO8601 cursor; rows strictly older than this
  limit?: number    // default & max 20
}): Promise<ActivityFeedResponse>

export function serializeActivity(row: ActivityRow & { actorName: string | null }): ActivityEvent
```
**Acceptance:**
- [ ] Feed returns newest-first, 20 per page, `hasMore=true` when a 21st row exists.
- [ ] `before` cursor returns only rows with `created_at < before`.
- [ ] Soft-deleted rows (`deleted_at` set) are excluded.

### Task 5: Invoice activity routes
**Blocks:** 11  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/invoices/activity.ts`
- Modify: `apps/zync-api/src/routes/invoices/index.ts` (mount sub-router)
**Steps:**
- [ ] `GET /api/invoices/:id/activity` — `requirePermission('invoices:read')`; parse `before` (ISO8601) + `limit` (≤20) via Zod; call `loadActivityFeed`; return `{ events, hasMore }`.
- [ ] `POST /api/invoices/:id/activity` — `requirePermission('invoices:write')`; Zod body `{ note: string (1–1000) }`; open a tx, call `appendInvoiceActivity` with `eventType: 'note_added'`, `actorType: 'user'`, `actorId: session.userId`, `note`; return the serialized event.
- [ ] `DELETE /api/invoices/:id/activity/:activityId` — `requirePermission('invoices:write')`; soft-delete only if row's `actor_id = session.userId` AND `created_at > now() - interval '15 minutes'` AND `event_type = 'note_added'`; set `deleted_at = now()`; else 403/410.
- [ ] All handlers scope by `tenant_id` (tenant isolation) and verify the parent invoice belongs to the tenant.
**Schema / Interfaces:**
```ts
// GET  /api/invoices/:id/activity   ?before=ISO8601&limit=20  -> ActivityFeedResponse
// POST /api/invoices/:id/activity   { note: string }          -> ActivityEvent
// DELETE /api/invoices/:id/activity/:activityId               -> { ok: true }
const noteSchema = z.object({ note: z.string().min(1).max(1000) })
const feedQuerySchema = z.object({
  before: z.string().datetime().optional(),
  limit: z.coerce.number().int().min(1).max(20).default(20),
})
```
**Acceptance:**
- [ ] GET without `invoices:read` → 403; POST without `invoices:write` → 403.
- [ ] POST creates a `note_added` row authored by the session user.
- [ ] DELETE by a non-author or after 15 minutes is rejected; by author within window soft-deletes.

### Task 6: Project activity routes
**Blocks:** 11  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/projects/activity.ts`
- Modify: `apps/zync-api/src/routes/projects/index.ts`
**Steps:**
- [ ] `GET /api/projects/:id/activity` — `requirePermission('projects:read')`; same feed logic, `scopeColumn: 'project_id'`.
- [ ] `POST /api/projects/:id/activity` — `requirePermission('projects:write')`; `appendProjectActivity` `eventType: 'note_added'`.
- [ ] `DELETE /api/projects/:id/activity/:activityId` — `requirePermission('projects:write')`; 15-minute author-only soft-delete.
- [ ] Tenant-scope and verify project ownership.
**Acceptance:**
- [ ] Feed/POST/DELETE behave identically to invoices, gated on `projects:*`.

### Task 7: Customer activity routes
**Blocks:** 11  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/customers/activity.ts`
- Modify: `apps/zync-api/src/routes/customers/index.ts`
**Steps:**
- [ ] `GET /api/customers/:id/activity` — `requirePermission('customers:read')`; `scopeColumn: 'customer_id'`.
- [ ] `POST /api/customers/:id/activity` — `requirePermission('customers:write')`; `appendCustomerActivity` `eventType: 'note_added'`.
- [ ] `DELETE /api/customers/:id/activity/:activityId` — `requirePermission('customers:write')`; 15-minute author-only soft-delete.
- [ ] Tenant-scope and verify customer ownership.
**Acceptance:**
- [ ] Feed/POST/DELETE behave identically to invoices, gated on `customers:*`.

### Task 8: Vendor activity routes
**Blocks:** 11  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/vendors/activity.ts`
- Modify: `apps/zync-api/src/routes/vendors/index.ts` (created by spec 182; mount here)
**Steps:**
- [ ] `GET /api/vendors/:id/activity` — `requirePermission('vendors:read')`; `scopeColumn: 'vendor_id'`. (Backs the Activity tab on `/vendors/:id`, spec 182.)
- [ ] `POST /api/vendors/:id/activity` — `requirePermission('vendors:write')`; `appendVendorActivity` `eventType: 'note_added'`.
- [ ] `DELETE /api/vendors/:id/activity/:activityId` — `requirePermission('vendors:write')`; 15-minute author-only soft-delete.
- [ ] Note prerequisite: the `vendors` table and `vendors:read`/`vendors:write` permissions are owned by spec 182 (`vendors-suppliers`); this task assumes they exist. If vendors is not yet built, this task is deferred but the table (Task 1) and helpers (Task 3) still ship.
**Acceptance:**
- [ ] Feed/POST/DELETE gated on `vendors:*`; rows scoped by `vendor_id` + `tenant_id`.

### Task 9: `ActivityTimeline` component
**Blocks:** 11  ·  **Blocked by:** 2, 10
**Files:**
- Create: `apps/zync-app/src/components/activity/ActivityTimeline.tsx`
- Create: `apps/zync-app/src/components/activity/ActivityEventRow.tsx`
- Create: `apps/zync-app/src/components/activity/AddNoteBox.tsx`
**Steps:**
- [ ] Render newest-first feed: system events (`actorType !== 'user'` or `eventType !== 'note_added'`) with a `●` marker; manual notes (`eventType === 'note_added'`) with a `✎` marker.
- [ ] Each row shows actor name (or "System" when `actorName` is null), the event description (formatted per `eventType` using metadata, e.g. `status_changed` → "Status changed: {from} → {to}"), the ISO timestamp localized via the project date formatter, and a context line.
- [ ] "+ Add note" reveals `AddNoteBox` (a labeled `Textarea`); `SHIFT+ENTER` submits, plain `ENTER` inserts newline; enforce 1–1000 chars client-side.
- [ ] "Load older" button appends the next page (driven by the hook's `fetchNextPage`); hidden when `hasMore` is false.
- [ ] Author may delete own note within 15 minutes (show a delete affordance only when `actorId === currentUserId` and age < 15min); calls the DELETE endpoint.
- [ ] Empty feed shows `EmptyState`.
- [ ] **A11y:** wrap the list in `role="feed"` with an `aria-label` (e.g. "Activity"); each row is an `<article>`/listitem with an accessible label; the note textarea has an associated `<label>`.
- [ ] **Motion:** guard any entry/expand transition behind `prefers-reduced-motion`.
- [ ] **RTL/i18n:** use `useDirection`; all literals via `translations`; marker/timestamp placement mirrors under RTL.
**Schema / Interfaces:**
```tsx
interface ActivityTimelineProps {
  entity: 'invoice' | 'project' | 'customer' | 'vendor'
  entityId: string
  canWrite: boolean   // gates the "+ Add note" affordance (entity :write permission)
}
export function ActivityTimeline(props: ActivityTimelineProps): JSX.Element
```
**Acceptance:**
- [ ] Renders feed with correct markers, newest-first, paginated 20/page.
- [ ] SHIFT+ENTER submits a note; plain ENTER does not.
- [ ] Own-note delete affordance appears only within the 15-minute window.
- [ ] `role="feed"` present; textarea is labeled; passes an axe check.
- [ ] Layout mirrors correctly in Hebrew/RTL; transitions disabled under reduced-motion.

### Task 10: `useActivityFeed` hook
**Blocks:** 9, 11  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/hooks/useActivityFeed.ts`
**Steps:**
- [ ] `useInfiniteQuery` keyed by `['activity', entity, entityId]`; page param is the `before` cursor (the oldest `createdAt` of the current page); `getNextPageParam` returns it when `hasMore`.
- [ ] Expose `addNote(note)` mutation (POST) and `deleteNote(activityId)` mutation (DELETE), both invalidating/optimistically updating the query.
- [ ] Map `entity` to the correct endpoint base (`/api/{invoices|projects|customers|vendors}/:id/activity`).
**Schema / Interfaces:**
```ts
export function useActivityFeed(entity: ActivityTimelineProps['entity'], entityId: string): {
  events: ActivityEvent[]
  hasMore: boolean
  isLoading: boolean
  fetchNextPage: () => void
  addNote: (note: string) => Promise<void>
  deleteNote: (activityId: string) => Promise<void>
}
```
**Acceptance:**
- [ ] Pagination fetches older pages via `before` cursor without duplicates.
- [ ] `addNote` shows the new note optimistically; `deleteNote` removes it.

### Task 11: Wire `ActivityTimeline` into detail pages
**Blocks:** —  ·  **Blocked by:** 5, 6, 7, 8, 9, 10
**Files:**
- Modify: `apps/zync-app/src/pages/invoices/InvoiceDetail.tsx`
- Modify: `apps/zync-app/src/pages/projects/ProjectDetail.tsx`
- Modify: `apps/zync-app/src/pages/customers/CustomerDetail.tsx`
- Modify: `apps/zync-app/src/pages/vendors/VendorDetail.tsx` (owned by spec 182; add Activity tab content)
**Steps:**
- [ ] Place `<ActivityTimeline>` below the main content on the right-side detail panel (desktop) / bottom section (mobile) for invoice, project, customer.
- [ ] On the vendor detail page, render `<ActivityTimeline entity="vendor">` inside the Activity tab.
- [ ] Pass `canWrite` from the user's resolved permission for each entity (`invoices:write` / `projects:write` / `customers:write` / `vendors:write`).
**Acceptance:**
- [ ] Each detail page shows its entity's live feed; adding a note appears immediately.
- [ ] `canWrite=false` hides the "+ Add note" affordance.

### Task 12: System-event emission integration points (cross-module)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: invoice state-change services in `apps/zync-api` (send/approve/pay/void/credit-note/email/recurring-generation flows)
- Modify: project state-change services (status change, member add/remove, invoice link, time logged)
- Modify: customer state-change services (created/updated/portal invite/portal activated/lead converted/archived)
**Steps:**
- [ ] At each entity state-change site, within the existing transaction, call the matching `append*Activity` helper with the appropriate `eventType` and `metadata` (e.g. `status_changed → { from, to }`, `sent → { proformaNumber }`).
- [ ] Automated flows (recurring invoice generation, webhook receipt) pass `actorId: null, actorType: 'system'`.
- [ ] This task documents the integration contract; the actual call sites live in the owning modules and may be implemented there — list them so no state change is left without an activity record (spec line 136).
**Acceptance:**
- [ ] Sending an invoice produces a `sent` activity row in the same transaction as the status update.
- [ ] A rolled-back state change leaves no orphaned activity row.
- [ ] System-generated events render as "System" in the timeline.
