# Per-Entity Activity Timeline

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 62  
**Tier:** All tiers  
**Depends on:** `invoices-core`, `projects-module`, `customers-module`, `audit-compliance`, `foundation-auth-rbac`  
**Referenced by:** `invoices-core`, `projects-module`, `customers-module`, `marketing-leads-pipeline`

---

## Overview

Inline chronological activity feed shown on entity detail pages: invoices, projects, customers, and leads. Each entity type has its own dedicated activity log table. This spec defines the shared UI component (`ActivityTimeline`) and the per-entity API endpoints and log tables.

Spec 28 (`audit-compliance`) owns the system-level `tenant_audit_log` for compliance. The per-entity timeline is distinct: it is user-facing, entity-scoped, and includes manual notes + system events in a single feed.

---

## Shared Component: `ActivityTimeline`

React component used inline on all entity detail pages. Rendered below the main content on the right-side detail panel (or as a bottom section on mobile).

```
── Activity ──────────────────────────────────────────────────
                                         [+ Add note]

  ● Alex Katz          Status changed: SENT → APPROVED
    2026-05-28 14:22   via portal (customer accepted)

  ✎ Alex Katz          "Customer confirmed they'll pay by end of June"
    2026-05-27 09:15   (note)

  ● System             Invoice generated from recurring template #4
    2026-05-26 08:00

  ● Dana Levi          Invoice created
    2026-05-26 07:58

────────────────────────────────────────────────────────────
[Load older]
```

- System events (status changes, automated actions) shown with `●`
- Manual notes shown with `✎`
- Newest-first order
- Pagination: 20 events per page, "Load older" button
- "+ Add note" opens inline textarea; `SHIFT+ENTER` submits

---

## Entity-Specific Tables

### `invoice_activities`

```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 DEFAULT 'user',            -- 'user' | 'system' | 'customer'
  event_type      TEXT NOT NULL,                  -- see event types below
  metadata        JSONB DEFAULT '{}',
  note            TEXT,                           -- for manual notes
  created_at      TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_invoice_activities_invoice ON invoice_activities(invoice_id, created_at DESC);
```

Invoice event types: `created`, `sent`, `approved`, `rejected`, `tax_issued`, `paid`, `voided`, `credit_noted`, `note_added`, `email_sent`, `viewed_by_customer`, `payment_initiated`.

### `project_activities`

```sql
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 DEFAULT 'user',
  event_type      TEXT NOT NULL,
  metadata        JSONB DEFAULT '{}',
  note            TEXT,
  created_at      TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_project_activities_project ON project_activities(project_id, created_at DESC);
```

Project event types: `created`, `status_changed`, `member_added`, `member_removed`, `note_added`, `invoice_linked`, `time_logged`.

### `customer_activities`

```sql
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 DEFAULT 'user',
  event_type      TEXT NOT NULL,
  metadata        JSONB DEFAULT '{}',
  note            TEXT,
  created_at      TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_customer_activities_customer ON customer_activities(customer_id, created_at DESC);
```

Customer event types: `created`, `updated`, `portal_user_invited`, `portal_user_activated`, `note_added`, `lead_converted`, `archived`.

### `vendor_activities`

```sql
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,
  actor_id        UUID REFERENCES users(id),
  actor_type      TEXT DEFAULT 'user',
  event_type      TEXT NOT NULL,
  metadata        JSONB DEFAULT '{}',
  note            TEXT,
  created_at      TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_vendor_activities_vendor ON vendor_activities(vendor_id, created_at DESC);
```

Vendor event types: `created`, `updated`, `withholding_cert_uploaded`, `withholding_cert_expiring`, `archived`. Backs the **Activity** tab on vendor detail (spec 182, `/vendors/:id`).

Leads use `lead_activities` (already defined in spec 22).

---

## System Event Writing

Every module that writes an entity state change **must** also insert an activity record in the same DB transaction:

```ts
await tx.insert(invoiceActivities).values({
  tenantId,
  invoiceId: invoice.id,
  actorId: session.userId,
  actorType: 'user',
  eventType: 'sent',
  metadata: { proformaNumber: invoice.proforma_number }
})
```

Automated system events (e.g. recurring invoice generation, webhook receipt) use `actorId: null, actorType: 'system'`.

---

## API Endpoints

```
GET  /api/invoices/:id/activity
     → invoice activity feed
       query: before=ISO8601 (cursor), limit=20
       returns: { events: ActivityEvent[], hasMore: bool }

POST /api/invoices/:id/activity
     → add manual note
       body: { note: string (1–1000 chars) }
       Requires: invoices:write

GET  /api/projects/:id/activity     → project feed (same shape)
POST /api/projects/:id/activity     → project note (requires projects:write)

GET  /api/customers/:id/activity    → customer feed (same shape)
POST /api/customers/:id/activity    → customer note (requires customers:write)
```

All GET endpoints require the entity's `:read` permission. Notes are soft-deletable by the author within 15 minutes (DELETE /api/.../activity/:activityId).

---

## ActivityEvent Shape

```ts
interface ActivityEvent {
  id: string
  actorId: string | null
  actorName: string | null   // resolved from users table
  actorType: 'user' | 'system' | 'customer'
  eventType: string
  metadata: Record<string, unknown>
  note: string | null
  createdAt: string          // ISO8601
}
```

`metadata` interpretation is event-type-specific (e.g. `status_changed` has `{ from, to }`).

---

## Foundation Deltas

**New tables:** `invoice_activities`, `project_activities`, `customer_activities`, `vendor_activities`

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Per-entity tables | Not a single unified `entity_activities` with `entity_type` + `entity_id` | Per-entity tables allow FK CASCADE DELETE, entity-specific indexes, and simpler queries; unified table hits PostgreSQL partial-index limits at scale |
| Separate from `tenant_audit_log` | Entity timeline ≠ compliance audit | Audit log is append-only, immutable, OWNER/ADMIN only (spec 28/50); timeline is user-facing, editable within 15min, shown to MEMBER/CONTRACTOR where appropriate |
| Same-transaction writes | Not async queue | Activity record must not be orphaned if main write fails; queue introduces ordering ambiguity |
| Cursor-based pagination | Not offset | Activity feeds grow unbounded; offset pagination degrades at depth |
