From 7d01fe47edb2a79bd6f61ceb356f87d04f4d5119 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Wed, 20 May 2026 15:56:00 -0500 Subject: [PATCH 01/26] docs(specs): events, announcements & forms subsystem design Brainstormed spec captures the v1 design: three concrete artifact tables (events, announcements, forms) with shared status/scope enums, six polymorphic cross-cutting tables (reviews, comments, broadcast requests + channels, revisions, form submissions), tier-flat authoring with a two-distinct-approver publish gate on the current revision, per-channel broadcast approval with hybrid posting strategies (native site_banner + workspace_chat, manual handoff for external social, blocked-on-#1965 for newsletter), Coltorapps headless form builder, unified /admin/queue triage surface, and an auth-gated /events/submit flow for member submissions. Sessions, committees, sponsors, n8n autoposting, and newsletter sending stay out of v1. --- ...05-20-events-announcements-forms-design.md | 603 ++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-events-announcements-forms-design.md diff --git a/docs/superpowers/specs/2026-05-20-events-announcements-forms-design.md b/docs/superpowers/specs/2026-05-20-events-announcements-forms-design.md new file mode 100644 index 000000000..f61cf1881 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-events-announcements-forms-design.md @@ -0,0 +1,603 @@ +# Events, Announcements & Forms Subsystem Design + +**Status:** Approved +**Date:** 2026-05-20 +**Related issues:** #1961 (events & sessions), #1963 (forms & surveys), #1965 (communications), #1974 (form builder), #1975 (form approval workflow), #1925 (n8n automation), #1924 (Zulip transition) +**Predecessors:** #1981 (groups subsystem — informs `host_group_id` + chair-scoped admin pattern), #1984 (orgs directory — informs `host_org_id` + visibility predicate) + +--- + +## Goal + +Add a unified events + announcements + forms subsystem to the admin app, with member-submitted content, multi-channel broadcast requests, and a two-approver gate before anything publishes. Sessions, committees, and sponsors stay out of v1 (their own brainstorm). + +## Architecture summary + +- **Three concrete artifact tables** — `events`, `announcements`, `forms` — each with type-specific columns and a shared `status` enum. +- **Six polymorphic cross-cutting tables** keyed on `(entity_type, entity_id)` — `artifact_reviews`, `artifact_comments`, `broadcast_requests`, `broadcast_channels`, `artifact_revisions`, `form_submissions`. (`audit_log` is reused, not duplicated.) +- **Tier-flat authoring + two-approver publish gate.** Anyone signed-in can author any artifact type as a draft. Publish requires two distinct staff approvals on the current revision; the author cannot self-approve. +- **Per-channel broadcast approval.** Author requests channels; approvers approve, decline, or add channels. Posting strategy is per-channel (`site_banner` + `workspace_chat` native; external social manual until n8n; newsletter blocked on #1965). +- **Coltorapps form builder** under the existing admin design system; schemas stored as `jsonb` we own. +- **Unified `/admin/queue`** as the canonical triage surface across artifact types. + +## Glossary + +- **Artifact** — generic term for an event, announcement, or form row. Used in shared column names and the queue. +- **Revision** — integer on each artifact, bumped when an author resubmits after `changes_requested`. Approvals are valid only for `revision = artifact.revision`. +- **Author class** — the role of the artifact's creator at submit time. Three classes: member, group lead, staff/super_admin. Drives defaults, not gates. +- **Reviewer** — any staff user (`systemTier ≥ 1`) other than the artifact's author. Can approve, reject, or request changes. +- **Channel** — a delivery destination for a broadcast request (`site_banner`, `workspace_chat`, `newsletter`, `twitter_x`, `bluesky`, `mastodon`, `linkedin`). + +--- + +## 1. Data model + +### 1.1 Migration (single migration file) + +`packages/api/migrations/0022_events_announcements_forms.sql`: + +```sql +-- Shared enums +CREATE TYPE artifact_status AS ENUM ( + 'draft', 'in_review', 'changes_requested', 'rejected', 'published', + 'cancelled', 'completed', 'expired', 'closed', 'archived' +); +CREATE TYPE artifact_scope AS ENUM ('public', 'community', 'group', 'staff_only'); +CREATE TYPE artifact_review_decision AS ENUM ('approve', 'reject', 'request_changes'); +CREATE TYPE broadcast_channel AS ENUM ( + 'site_banner', 'workspace_chat', 'newsletter', + 'twitter_x', 'bluesky', 'mastodon', 'linkedin' +); +CREATE TYPE broadcast_channel_status AS ENUM ('requested', 'approved', 'declined', 'posted'); +CREATE TYPE artifact_entity_type AS ENUM ('event', 'announcement', 'form', 'group'); + +-- Extend existing events table +-- IMPORTANT backfill: existing events are already live on the public site, so they default +-- to 'published' + 'public' and migrate forward. New rows default to 'draft' + 'community'. +ALTER TABLE events + ADD COLUMN status artifact_status NOT NULL DEFAULT 'published', + ADD COLUMN revision int NOT NULL DEFAULT 1, + ADD COLUMN author_id uuid REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN scope artifact_scope NOT NULL DEFAULT 'public', + ADD COLUMN host_group_id uuid REFERENCES groups(id) ON DELETE SET NULL, + ADD COLUMN host_org_id uuid REFERENCES organizations(id) ON DELETE SET NULL, + ADD COLUMN external_url text, + ADD COLUMN thumbnail_key text; + +-- Flip the column defaults so new inserts behave correctly +ALTER TABLE events ALTER COLUMN status SET DEFAULT 'draft'; +ALTER TABLE events ALTER COLUMN scope SET DEFAULT 'community'; + +-- New announcements table +CREATE TABLE announcements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + status artifact_status NOT NULL DEFAULT 'draft', + revision int NOT NULL DEFAULT 1, + author_id uuid REFERENCES users(id) ON DELETE SET NULL, + scope artifact_scope NOT NULL DEFAULT 'community', + host_group_id uuid REFERENCES groups(id) ON DELETE SET NULL, + host_org_id uuid REFERENCES organizations(id) ON DELETE SET NULL, + title text NOT NULL, + body text NOT NULL, + link_url text, + expires_at timestamptz, + thumbnail_key text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); + +-- New forms table +CREATE TABLE forms ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + status artifact_status NOT NULL DEFAULT 'draft', + revision int NOT NULL DEFAULT 1, + author_id uuid REFERENCES users(id) ON DELETE SET NULL, + scope artifact_scope NOT NULL DEFAULT 'community', + host_group_id uuid REFERENCES groups(id) ON DELETE SET NULL, + slug text NOT NULL UNIQUE, + title text NOT NULL, + description text, + schema jsonb NOT NULL, + entity_type artifact_entity_type, + entity_id uuid, + accepts_submissions boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CHECK ((entity_type IS NULL) = (entity_id IS NULL)) +); + +-- Polymorphic cross-cutting tables +CREATE TABLE artifact_reviews ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type artifact_entity_type NOT NULL, + entity_id uuid NOT NULL, + entity_revision int NOT NULL, + reviewer_id uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + decision artifact_review_decision NOT NULL, + comment text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX artifact_reviews_entity_idx + ON artifact_reviews (entity_type, entity_id, entity_revision); + +CREATE TABLE artifact_comments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type artifact_entity_type NOT NULL, + entity_id uuid NOT NULL, + author_id uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX artifact_comments_entity_idx + ON artifact_comments (entity_type, entity_id, created_at); + +CREATE TABLE broadcast_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type artifact_entity_type NOT NULL, + entity_id uuid NOT NULL, + created_by uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (entity_type, entity_id) -- one broadcast_request per artifact lifetime +); + +CREATE TABLE broadcast_channels ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + broadcast_request_id uuid NOT NULL REFERENCES broadcast_requests(id) ON DELETE CASCADE, + channel broadcast_channel NOT NULL, + status broadcast_channel_status NOT NULL DEFAULT 'requested', + decided_by uuid REFERENCES users(id) ON DELETE SET NULL, + decided_at timestamptz, + posted_by uuid REFERENCES users(id) ON DELETE SET NULL, + posted_at timestamptz, + post_url text, + decline_reason text, + prepared_text text, + prepared_image_key text, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (broadcast_request_id, channel) +); +CREATE INDEX broadcast_channels_status_idx + ON broadcast_channels (status, channel) + WHERE status IN ('approved', 'posted'); + +CREATE TABLE form_submissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + form_id uuid NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + form_revision int NOT NULL, + submitter_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + payload jsonb NOT NULL, + submitted_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX form_submissions_form_idx ON form_submissions (form_id, submitted_at DESC); + +-- Status-filtered queue indexes +CREATE INDEX events_status_idx ON events (status, created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX announcements_status_idx ON announcements (status, created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX forms_status_idx ON forms (status, created_at DESC) WHERE deleted_at IS NULL; +``` + +### 1.2 Drizzle schema + +- New file `packages/api/src/db/schema/announcements.ts` declares `announcements` + the four shared enums. +- New file `packages/api/src/db/schema/forms.ts` declares `forms` + `formSubmissions`. +- New file `packages/api/src/db/schema/artifacts.ts` declares all polymorphic cross-cutting tables (`artifactReviews`, `artifactComments`, `broadcastRequests`, `broadcastChannels`) + shared enums (`artifactEntityType`, `broadcastChannel`, `broadcastChannelStatus`). +- Existing `packages/api/src/db/schema/events.ts` extended with the new columns + `status` / `revision` / `author_id` etc. +- All registered in `packages/api/src/db/schema/index.ts`. + +### 1.3 Polymorphic FK consistency + +Postgres can't enforce referential integrity on `(entity_type, entity_id)` at the DB level. Two mitigations: + +1. **Code-level guards.** Helpers in `packages/api/src/lib/artifacts/` resolve `(entity_type, entity_id)` to a typed artifact row and return 404 if not found; all polymorphic-table writes go through these helpers. +2. **Nightly orphan scan.** A scheduled Worker queries each polymorphic table for rows whose target artifact no longer exists and logs them. Same pattern as the existing `audit_log.target_*` columns. + +--- + +## 2. Lifecycle state machine + +### 2.1 States and transitions + +``` +draft + ↓ submit_for_review (author) +in_review + ↓ approve (reviewer != author, count valid approvals) + ├─ 1 approval on revision → stays in_review (UI shows "1 of 2 approvals") + ├─ 2 approvals on revision → published + ↓ request_changes (reviewer) → changes_requested + ↓ reject (reviewer) → rejected (terminal) +changes_requested + ↓ resubmit (author) → revision++; in_review with 0 approvals on new revision +rejected (terminal) +published + ├─ cancel (events, staff) → cancelled + ├─ archive (staff) → archived + ├─ close (forms, staff) → closed + ├─ auto past end_date → completed (events) + └─ auto past expires_at → expired (announcements) +``` + +### 2.2 Per-artifact subset of states + +| State | Events | Announcements | Forms | +|---|---|---|---| +| `draft`, `in_review`, `changes_requested`, `rejected`, `published`, `archived` | ✓ | ✓ | ✓ | +| `cancelled` | ✓ | — | — | +| `completed` (auto) | ✓ | — | — | +| `expired` (auto) | — | ✓ | — | +| `closed` | — | — | ✓ | + +### 2.3 Approval validity and revision invalidation + +Stored: `artifact_reviews(entity_type, entity_id, entity_revision, reviewer_id, decision)`. + +Counting valid approvals to publish: + +```sql +SELECT COUNT(DISTINCT reviewer_id) +FROM artifact_reviews +WHERE entity_type = ? + AND entity_id = ? + AND entity_revision = (SELECT revision FROM WHERE id = ?) + AND decision = 'approve' + AND reviewer_id != (SELECT author_id FROM
WHERE id = ?); +-- Publish when count >= 2 +``` + +`COUNT(DISTINCT reviewer_id)` enforces "two distinct approvers" mechanically. The author-id exclusion enforces the self-promotion guard. `entity_revision` equality means resubmits implicitly invalidate prior approvals — no UPDATE-cascading needed. + +### 2.4 Single-reviewer transitions + +- `request_changes` — any single reviewer can send the artifact back. Their comment is required. +- `reject` — any single reviewer can terminate the artifact. Reason comment required. + +Both write a row to `artifact_reviews` with the appropriate `decision` and a row to `audit_log`. + +### 2.5 Auto-transitions (`completed`, `expired`) + +Computed at **read time** for the artifact's effective status, the same pattern badges use: + +```ts +function effectiveStatus(a: Event | Announcement): ArtifactStatus { + if (a.status === "published") { + if (a.type === "event" && a.endDate && a.endDate < today) return "completed"; + if (a.type === "announcement" && a.expiresAt && a.expiresAt < now) return "expired"; + } + return a.status; +} +``` + +A **nightly cron** also UPDATEs the stored `status` for queue accuracy and audit cleanliness — but every read still applies the function so a race window doesn't surface stale "published" to the public site. + +--- + +## 3. Authoring & permissions + +### 3.1 Default-by-author-class + +| Author class | Event scope | Announcement scope | Form scope | host_group_id | host_org_id | +|---|---|---|---|---|---| +| Member | `community` | `community` | `community` | none | editable | +| Group lead | `group` | `group` | `group` | their group | none | +| Staff / super_admin | `community` | `community` | `community` | none | none | + +All defaults are editable by the author and by any approver during review. + +### 3.2 Policies (new) + +```ts +// packages/api/src/lib/policies/canEditArtifact.ts +export const canEditArtifact = ( + a: ActorContext, + scope: { entityType: ArtifactEntityType; entityId: string; status: ArtifactStatus; authorId: string } +): boolean => { + if (a.systemTier >= 1) return true; + // Authors can edit their own drafts and changes_requested + return a.id === scope.authorId + && (scope.status === "draft" || scope.status === "changes_requested"); +}; + +// packages/api/src/lib/policies/canReviewArtifact.ts +export const canReviewArtifact = ( + a: ActorContext, + scope: { authorId: string } +): boolean => a.systemTier >= 1 && a.id !== scope.authorId; + +// canPublishArtifact = canReviewArtifact; the 2-approval count is checked at the call site +// canBroadcastDecide = staff-only on broadcast_channels writes +``` + +### 3.3 Existing policies extended + +- `canEnterAdminApp` is unchanged — author members reach the admin app through their existing membership tier (any signed-in `systemTier ≥ 0` member can hit the admin app, with most routes guarded individually). Members can reach `/admin/events/new` etc. because creating drafts is permitted; they cannot reach `/admin/queue` (staff-only). +- Group-lead identification reuses the existing `chairedGroupIds` set already on `ActorContext`. + +--- + +## 4. Broadcast subsystem + +### 4.1 Posting strategies per channel + +A dispatcher in `packages/api/src/lib/broadcast/dispatcher.ts` maps each channel kind to one of three strategies: + +| Channel | Strategy | v1 behavior | +|---|---|---| +| `site_banner` | **native** | Approving channel + artifact `published` flips a read predicate; the public site reads the banner via `GET /api/announcements/active-banner` | +| `workspace_chat` | **native via webhook** | Approval triggers a POST to `WORKSPACE_CHAT_WEBHOOK` env (currently a Slack incoming webhook URL; swappable to Zulip per #1924 by changing the env value and request body shape in one adapter file) | +| `newsletter` | **blocked** | Channel exists in the queue with a "blocked on #1965 Communications" indicator; status stays `requested` | +| `twitter_x`, `bluesky`, `mastodon`, `linkedin` | **manual handoff** | Approval moves channel row to `/admin/broadcasts` sub-queue with prepared text + image; staff posts externally and pastes the `post_url` back to mark `posted` | + +The dispatcher signature: + +```ts +type ChannelPoster = ( + artifact: ArtifactRow, + channel: BroadcastChannelRow +) => Promise<{ posted: true; postUrl?: string } | { posted: false; reason: "blocked" | "manual" }>; +``` + +When n8n lands (#1925), the four manual entries become n8n webhook calls — single file change in `dispatcher.ts`. The schema is unchanged. + +### 4.2 Channel data flow + +``` +Author submits artifact (status=in_review) + broadcast_request with N broadcast_channels rows (status=requested). + ↓ +On the Broadcast tab of the artifact detail page, reviewers can: + - approve / decline each channel independently + - ADD a channel the author didn't request (INSERT broadcast_channels row directly approved by the reviewer) + - leave decline_reason on declined channels + ↓ +When artifact transitions to published: + - native channels (site_banner, workspace_chat) auto-fire via dispatcher + - manual channels move to /admin/broadcasts sub-queue + - newsletter rows show blocked indicator + ↓ +Manual channel rows close when staff pastes a post_url back (channel.status = 'posted', posted_at = now()) +``` + +### 4.3 `/admin/broadcasts` sub-queue + +A filtered view of `broadcast_channels` with `status = 'approved' AND posted_at IS NULL AND channel IN (manual-strategy channels)`. Columns: artifact title, channel, prepared text, prepared image thumbnail, "Mark posted" action (modal taking `post_url`). + +### 4.4 Thumbnails / prepared image storage + +- New R2 binding `ARTIFACT_THUMBNAILS` paralleling `ORGANIZATION_LOGOS`. +- Each artifact has at most one thumbnail (`thumbnail_key` on the artifact row). +- Broadcast channels can carry a per-channel `prepared_image_key` for platform-specific crops (e.g., square for one, wide for another); when null, the channel uses the artifact's thumbnail. +- Upload endpoint mirrors the existing org-logo upload pattern (signed URL → R2 PUT → server-side validate). + +--- + +## 5. Forms subsystem + +### 5.1 Library choice — Coltorapps Builder (MIT, headless) + +`apps/admin/src/lib/form-builder/` wraps Coltorapps primitives with the admin design system (`EditorialInput`, `EditorialTextarea`, `EditorialSelect`, `EditorialCheckbox`). The vendor never reaches CSS for builder UI. + +### 5.2 Schema persistence + +The Coltorapps schema is stored as `forms.schema jsonb`. The format is plain JSON we own; if Coltorapps is unmaintained, the renderer survives a builder rewrite. + +Server-side normalization in `packages/api/src/lib/forms/schemaParser.ts`: + +- Cap field count at 50. +- Cap option count per choice field at 30. +- Reject unknown field types (whitelist: `text`, `textarea`, `email`, `url`, `number`, `date`, `single_choice`, `multi_choice`, `checkbox`). +- Strip any fields not on the whitelist before persisting; log the strip in audit. + +### 5.3 Submission storage + +`form_submissions(form_id, form_revision, submitter_user_id, payload, submitted_at)`. The submitted `payload` is the raw map of field-id → value. `form_revision` records which schema revision was answered, so an admin's CSV export against an old schema stays interpretable. + +Anonymous submissions allowed when `forms.scope = 'public'`. Otherwise the public route requires auth. + +### 5.4 Public renderer + +`apps/web/src/pages/forms/FormPage.tsx` at `/forms/:slug`: + +- Reads `GET /api/forms/:slug` (returns schema + metadata, only if `status='published'` and `accepts_submissions=true`). +- Renders schema via the same design-system primitives. +- POSTs to `/api/forms/:slug/submissions` with payload. +- Returns a thank-you state on success. + +File uploads explicitly **out of scope for v1**. + +### 5.5 Form ↔ entity attachment + +`forms.entity_type` + `forms.entity_id` polymorphic columns attach a form to one event / announcement / group. Constraint `CHECK ((entity_type IS NULL) = (entity_id IS NULL))` enforces both-or-neither. + +When the parent artifact transitions to `cancelled` or `archived`, the form is auto-`closed` (`accepts_submissions = false`); submission history is retained. + +Reusing a single form across multiple entities is **out of scope** (would require a join table; v2). + +--- + +## 6. Admin UI surfaces + +### 6.1 Routes + +``` +/admin/queue -- unified triage (filterable by type, scope, age, submitter role) +/admin/events -- list (filter by status, scope, host, author role) +/admin/events/new -- compose modal/page +/admin/events/:id -- detail editor (tabs: Identity / Content / Broadcast / Review / Audit) +/admin/announcements -- list +/admin/announcements/new +/admin/announcements/:id -- detail (same tab pattern) +/admin/forms -- list +/admin/forms/new -- Coltorapps builder surface +/admin/forms/:id -- detail + inline builder + attach-to-entity + status controls +/admin/forms/:id/submissions -- paginated submission table + CSV export +/admin/broadcasts -- manual-handoff sub-queue +``` + +### 6.2 Sidebar nav + +New top-level **Queue** entry with a live badge count of `in_review` artifacts (sum across types, scoped to artifacts the actor `canReviewArtifact` of). Beneath: + +- Queue (badge: count of reviewable in_review artifacts) +- Events +- Announcements +- Forms +- Broadcasts (badge: count of approved + unposted manual channels) + +### 6.3 Artifact detail tab pattern (consistent across event / announcement / form) + +| Tab | Contents | +|---|---| +| **Identity** | Title, type (events), scope, host_group, host_org, dates (events) or expires_at (announcements), slug (forms), author attribution chip | +| **Content** | Description / body (markdown), thumbnail upload, external_url (events), inline form builder (forms only) | +| **Broadcast** | Channels requested + per-channel state, "add channel" affordance for reviewers, prepared text + per-channel image | +| **Review** | Approval state ("1 of 2 approvals on revision 3"), approver list, action affordances (Submit for review / Approve / Reject / Request changes), inline comment thread | +| **Audit** | Last 50 `audit_log` rows for this artifact, formatted | + +### 6.4 `/admin/queue` columns + +| Column | Notes | +|---|---| +| Type chip | event / announcement / form | +| Title | links to detail page | +| Submitter | display name + role chip (member / group-lead / staff) | +| Scope | scope chip | +| Revision | "rev 3" | +| Approvals | "0 of 2" / "1 of 2" | +| Age | time-since-submit | +| Action | quick approve / open detail | + +Default sort: oldest-submitted first. Filters: type, scope, submitter role, age bucket. + +--- + +## 7. Public surfaces + +### 7.1 Existing routes extended + +| Path | Change | +|---|---| +| `/events` | Filter by `scope = 'public'` always; add `scope = 'community'` and `scope = 'group'` rows when the viewer matches. Render "Submitted by @member" chip on member-authored events. Add an auth-gated "Submit an event" CTA | +| `/events/:slug` | Render `external_url` CTA when set ("Register at {host name}"). Render attached form (if `forms.entity_type='event'` matches) inline. Render `host_group` / `host_org` attribution | + +### 7.2 New public routes + +| Path | Purpose | +|---|---| +| `/events/submit` | Auth-gated event submission form. Composes a `draft` event and transitions it to `in_review`. Renders the broadcast-channel multi-select | +| `/forms/:slug` | Public form renderer | + +### 7.3 Banner rendering + +`` mounted in `apps/web/src/App.tsx` shell: + +- Calls `GET /api/announcements/active-banner` once on app mount. +- Endpoint returns **at most one** announcement: the most-recently-published row whose effective status is `published` (not auto-`expired`) and which has at least one `broadcast_channels` row with `channel='site_banner' AND status='posted'`. Single active banner is intentional — stacking competing banners is dismiss-fatigue we don't want in v1. +- The response is independent of dismissal state; dismissal is enforced client-side via `localStorage` key `dismissed_banner_`. +- Renders as a dismissible strip at the top of every public page. Dismiss button writes the localStorage key. + +### 7.4 No public `/announcements` index in v1 + +Past announcements live in admin only. If a public archive is needed later, add `/announcements` as a standalone list page reading `status IN ('published', 'expired')`. + +--- + +## 8. Audit & comments + +### 8.1 Audit verbs + +Reuse the existing `audit_log` table. Verbs scoped per artifact type: + +- `events.create | events.update | events.submit_for_review | events.approve | events.reject | events.request_changes | events.cancel | events.archive | events.publish` +- Mirror set for `announcements.*` and `forms.*`. +- `broadcast_channels.approve | broadcast_channels.decline | broadcast_channels.mark_posted | broadcast_channels.add_by_reviewer` +- `form_submissions.create` (for admin visibility). + +Each row captures actor, target_type, target_id, before/after JSON. + +### 8.2 Comments + +`artifact_comments(entity_type, entity_id, author_id, body, created_at)` — flat thread keyed on artifact, **not** revision-bound (comments persist across resubmits). Visible to: +- The artifact author +- All staff (`systemTier ≥ 1`) + +Never surfaced publicly after publish. No threading, mentions, or rich text in v1. + +--- + +## 9. Testing strategy + +### 9.1 API (vitest) + +- **Lifecycle library** (`packages/api/src/lib/lifecycle/`) — one test file per artifact type. Cover every transition, every guard: + - Self-approve rejection + - Two-distinct-reviewers enforcement + - Revision-invalidation of prior approvals + - Single-reviewer reject/request-changes + - Auto-transitions (completed, expired) +- **Policies** — extend the existing `policies.test.ts` with `canEditArtifact` + `canReviewArtifact`. +- **Routes** — one suite per route file (`events.ts`, `announcements.ts`, `forms.ts`, admin counterparts). Hit happy path + the 5 most-likely-broken edge cases per route. +- **Broadcast dispatcher** — unit tests with mocked webhook calls; assert correct payload shape for native channels, correct queue placement for manual channels. +- **Form schema parser** — independent unit tests covering caps, unknown-type rejection, payload-vs-schema validation. + +### 9.2 Admin frontend (Playwright) + +Smoke tests under existing Playwright setup: + +1. Staff login → create event → submit for review → second staff approves → channels post (mock webhook). +2. Member login → submit external event → staff sees in queue → reject → audit row written. +3. Staff create form → publish → public submit → admin sees submission. +4. Staff request changes → author resubmits → first approval invalidated. + +### 9.3 Public frontend (vitest + Testing Library) + +- `` — renders when active, hides when dismissed, hides on expired. +- `/events/submit` — auth-redirect, scope defaults match author class. +- `` — schema renders, anonymous vs auth-gated, submit success state. + +--- + +## 10. Out of scope (v2+) + +| Item | Why deferred | +|---|---| +| **Sessions** (event agenda + presenter assignment) | Own brainstorm — separate state machine for sessions, presenter privacy considerations | +| **Committees** (chair/co-chair/area-lead assignment) | Internal coordination; large UI surface | +| **Sponsors** (event_sponsorships) | Blocks on organization sponsor-tier model | +| **n8n autoposting** for external social | Blocks on #1925 (n8n deploy); v1 uses manual handoff | +| **Newsletter channel sending** | Blocks on #1965 Communications | +| **Form file uploads** | Requires new R2 binding + virus-scan path; text-only in v1 | +| **Form analytics** | Counts only in v1; no funnel/dropoff | +| **One form attached to many entities** | Polymorphic columns enforce 1:1; v2 schema change | +| **Public `/announcements` index** | YAGNI; only via channels in v1 | +| **Mentions / threaded comments** | Flat list only | +| **Per-channel approver roles** | Single `staff+` gate on all channel decisions | +| **Cancellation broadcast** ("X was cancelled" banner) | Useful affordance, not v1 | + +--- + +## 11. Risks + +| Risk | Mitigation | +|---|---| +| Polymorphic FK has no DB-level integrity | Code-level resolver helpers + nightly orphan scan; same pattern as existing `audit_log.target_*` | +| UNION ALL queue query slow at scale | Microseconds at current scale; if grows past hundreds of in-review rows, add a `queue_index` materialized table via app-side dual-write | +| Coltorapps maintenance risk | We own the stored schemas (jsonb); renderer is built on our own primitives; only the builder UI would need a rewrite if the lib is abandoned | +| Slack webhook leak | Webhook URL stored as Worker secret; rotated like any other secret | +| Two-approver bottleneck when only one staff online | Documented as expected workflow constraint; no fallback path in v1 (revisit if it becomes painful) | +| Member-submission noise floods the queue | Triage filter by submitter role; `reject` exists as a terminal close-out so the queue stays clean | +| Banner annoyance / dismiss fatigue | One active banner at a time; dismissal persists per banner-id in localStorage; banner only renders on `posted` site_banner channels (not every published announcement) | + +--- + +## 12. Open questions tagged for the implementation plan + +- Whether the unified queue's badge count should reflect **all** in_review artifacts or only **reviewable-by-me** (excluding ones the actor authored). Leaning toward reviewable-by-me; finalize during implementation. +- Whether `cancelled` events still appear in `/events` (struck through) or are hidden entirely. Leaning struck-through with explicit "Cancelled" marker for the day-of refresher case. +- Whether `archived` is reachable from `cancelled`/`completed`/`expired`, or only from `published`. Leaning yes — any non-terminal-but-finished state can be archived for queue cleanliness. + +--- + +## Approval + +Brainstormed with the user 2026-05-20. Approved across three section reviews (data architecture, workflow subsystems, UI surfaces). Implementation plan to follow. From c68da471cf555f948a6a0ad06396f3ec168aedbb Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Wed, 20 May 2026 16:09:21 -0500 Subject: [PATCH 02/26] docs(plans): artifact subsystem foundation implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1 of 5 covering migration 0022 (events extension + 5 new tables + 6 enums), Drizzle schema modules, lifecycle library (transitions, approvals, effectiveStatus, applyTransition orchestrator with Drizzle adapter), canEditArtifact / canReviewArtifact policies, comment sanitizer, GET /admin/queue (UNION ALL across in_review artifacts), GET /announcements/active-banner stub, and TEST_BYPASS_AUTH integration harness with a full submit → 2-approve → publish smoke. Plans 2-5 (events, announcements, forms, broadcast) sit on this foundation. --- ...act-subsystem-foundation-implementation.md | 3230 +++++++++++++++++ 1 file changed, 3230 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-artifact-subsystem-foundation-implementation.md diff --git a/docs/superpowers/plans/2026-05-20-artifact-subsystem-foundation-implementation.md b/docs/superpowers/plans/2026-05-20-artifact-subsystem-foundation-implementation.md new file mode 100644 index 000000000..c79228920 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-artifact-subsystem-foundation-implementation.md @@ -0,0 +1,3230 @@ +# Artifact Subsystem Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the shared schema, lifecycle library, policies, audit/comment helpers, and unified queue endpoint that all three artifact subsystems (events, announcements, forms) will sit on top of. No user-facing UI in this plan — Plans 2-5 add that. + +**Architecture:** Migration 0022 adds 5 new tables + extends `events` with shared columns + adds 6 new pg enums. A new `packages/api/src/lib/lifecycle/` module owns the state machine, approval counting, and atomic transition writes. Two new policies (`canEditArtifact`, `canReviewArtifact`) gate writes; the existing `requirePolicy` middleware wraps them onto routes. A single new admin endpoint (`GET /admin/queue`) returns the unified in-review surface across artifact types via UNION ALL. A stub public endpoint (`GET /api/announcements/active-banner`) returns `null` for now — Plan 3 fills it in when announcements ship. + +**Tech Stack:** Drizzle ORM, Hono on Cloudflare Workers, Neon Postgres (HTTP driver), Zod, Vitest. All existing patterns from the groups + orgs subsystems. + +**Spec:** [`docs/superpowers/specs/2026-05-20-events-announcements-forms-design.md`](../specs/2026-05-20-events-announcements-forms-design.md) + +--- + +## Pre-flight + +- [ ] **Check current branch and clean working tree** + +```bash +git status -sb +``` + +Expected: on `cdcore09/site-redesign`, working tree clean. If not, stash or commit before continuing. + +- [ ] **Verify baseline typecheck passes** + +```bash +npm run typecheck +``` + +Expected: `Tasks: 5 successful, 5 total`. Fail = baseline is already broken; stop and fix before continuing. + +- [ ] **Create a feature branch** + +```bash +git checkout -b cdcore09/artifact-foundation +``` + +- [ ] **Confirm migration number is 0022** + +```bash +ls packages/api/migrations/ | grep -E '^[0-9]{4}_' | tail -3 +``` + +Expected: `0019_groups_publishable.sql`, `0020_organizations_public_profile.sql`, `0021_profile_slack_username.sql`. Next migration index is 0022. + +--- + +## Task 1: Migration SQL — 0022_artifact_subsystem + +**Files:** +- Create: `packages/api/migrations/0022_artifact_subsystem.sql` +- Modify: `packages/api/migrations/meta/_journal.json` + +- [ ] **Step 1: Create the migration SQL file** + +Write `packages/api/migrations/0022_artifact_subsystem.sql`: + +```sql +-- Shared enums --------------------------------------------------------------- + +CREATE TYPE "artifact_status" AS ENUM ( + 'draft', + 'in_review', + 'changes_requested', + 'rejected', + 'published', + 'cancelled', + 'completed', + 'expired', + 'closed', + 'archived' +);--> statement-breakpoint + +CREATE TYPE "artifact_scope" AS ENUM ( + 'public', + 'community', + 'group', + 'staff_only' +);--> statement-breakpoint + +CREATE TYPE "artifact_review_decision" AS ENUM ( + 'approve', + 'reject', + 'request_changes' +);--> statement-breakpoint + +CREATE TYPE "broadcast_channel" AS ENUM ( + 'site_banner', + 'workspace_chat', + 'newsletter', + 'twitter_x', + 'bluesky', + 'mastodon', + 'linkedin' +);--> statement-breakpoint + +CREATE TYPE "broadcast_channel_status" AS ENUM ( + 'requested', + 'approved', + 'declined', + 'posted' +);--> statement-breakpoint + +CREATE TYPE "artifact_entity_type" AS ENUM ( + 'event', + 'announcement', + 'form', + 'group' +);--> statement-breakpoint + +-- Extend existing events table ---------------------------------------------- +-- Existing events are already live on the public site, so they default to +-- status='published' + scope='public'. After backfill we flip the column +-- defaults so new INSERTs default to draft/community. + +ALTER TABLE "events" + ADD COLUMN "status" "artifact_status" NOT NULL DEFAULT 'published', + ADD COLUMN "revision" integer NOT NULL DEFAULT 1, + ADD COLUMN "author_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + ADD COLUMN "scope" "artifact_scope" NOT NULL DEFAULT 'public', + ADD COLUMN "host_group_id" uuid REFERENCES "groups"("id") ON DELETE SET NULL, + ADD COLUMN "host_org_id" uuid REFERENCES "organizations"("id") ON DELETE SET NULL, + ADD COLUMN "external_url" text, + ADD COLUMN "thumbnail_key" text;--> statement-breakpoint + +ALTER TABLE "events" ALTER COLUMN "status" SET DEFAULT 'draft';--> statement-breakpoint +ALTER TABLE "events" ALTER COLUMN "scope" SET DEFAULT 'community';--> statement-breakpoint + +CREATE INDEX "events_status_idx" ON "events" ("status", "created_at" DESC) + WHERE "deleted_at" IS NULL;--> statement-breakpoint + +-- announcements -------------------------------------------------------------- + +CREATE TABLE "announcements" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "status" "artifact_status" NOT NULL DEFAULT 'draft', + "revision" integer NOT NULL DEFAULT 1, + "author_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "scope" "artifact_scope" NOT NULL DEFAULT 'community', + "host_group_id" uuid REFERENCES "groups"("id") ON DELETE SET NULL, + "host_org_id" uuid REFERENCES "organizations"("id") ON DELETE SET NULL, + "title" text NOT NULL, + "body" text NOT NULL, + "link_url" text, + "expires_at" timestamptz, + "thumbnail_key" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz +);--> statement-breakpoint + +CREATE INDEX "announcements_status_idx" ON "announcements" ("status", "created_at" DESC) + WHERE "deleted_at" IS NULL;--> statement-breakpoint + +-- forms ---------------------------------------------------------------------- + +CREATE TABLE "forms" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "status" "artifact_status" NOT NULL DEFAULT 'draft', + "revision" integer NOT NULL DEFAULT 1, + "author_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "scope" "artifact_scope" NOT NULL DEFAULT 'community', + "host_group_id" uuid REFERENCES "groups"("id") ON DELETE SET NULL, + "slug" text NOT NULL UNIQUE, + "title" text NOT NULL, + "description" text, + "schema" jsonb NOT NULL, + "entity_type" "artifact_entity_type", + "entity_id" uuid, + "accepts_submissions" boolean NOT NULL DEFAULT true, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + CONSTRAINT "forms_entity_both_or_neither" + CHECK (("entity_type" IS NULL) = ("entity_id" IS NULL)) +);--> statement-breakpoint + +CREATE INDEX "forms_status_idx" ON "forms" ("status", "created_at" DESC) + WHERE "deleted_at" IS NULL;--> statement-breakpoint + +CREATE INDEX "forms_entity_idx" ON "forms" ("entity_type", "entity_id") + WHERE "entity_type" IS NOT NULL;--> statement-breakpoint + +-- form_submissions ----------------------------------------------------------- + +CREATE TABLE "form_submissions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "form_id" uuid NOT NULL REFERENCES "forms"("id") ON DELETE CASCADE, + "form_revision" integer NOT NULL, + "submitter_user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "payload" jsonb NOT NULL, + "submitted_at" timestamptz NOT NULL DEFAULT now() +);--> statement-breakpoint + +CREATE INDEX "form_submissions_form_idx" ON "form_submissions" ("form_id", "submitted_at" DESC);--> statement-breakpoint + +-- artifact_reviews ----------------------------------------------------------- + +CREATE TABLE "artifact_reviews" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "entity_type" "artifact_entity_type" NOT NULL, + "entity_id" uuid NOT NULL, + "entity_revision" integer NOT NULL, + "reviewer_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE RESTRICT, + "decision" "artifact_review_decision" NOT NULL, + "comment" text, + "created_at" timestamptz NOT NULL DEFAULT now() +);--> statement-breakpoint + +CREATE INDEX "artifact_reviews_entity_idx" + ON "artifact_reviews" ("entity_type", "entity_id", "entity_revision");--> statement-breakpoint + +-- artifact_comments ---------------------------------------------------------- + +CREATE TABLE "artifact_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "entity_type" "artifact_entity_type" NOT NULL, + "entity_id" uuid NOT NULL, + "author_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE RESTRICT, + "body" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now() +);--> statement-breakpoint + +CREATE INDEX "artifact_comments_entity_idx" + ON "artifact_comments" ("entity_type", "entity_id", "created_at");--> statement-breakpoint + +-- broadcast_requests --------------------------------------------------------- + +CREATE TABLE "broadcast_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "entity_type" "artifact_entity_type" NOT NULL, + "entity_id" uuid NOT NULL, + "created_by" uuid NOT NULL REFERENCES "users"("id") ON DELETE RESTRICT, + "created_at" timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "broadcast_requests_unique_per_artifact" + UNIQUE ("entity_type", "entity_id") +);--> statement-breakpoint + +-- broadcast_channels --------------------------------------------------------- + +CREATE TABLE "broadcast_channels" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "broadcast_request_id" uuid NOT NULL + REFERENCES "broadcast_requests"("id") ON DELETE CASCADE, + "channel" "broadcast_channel" NOT NULL, + "status" "broadcast_channel_status" NOT NULL DEFAULT 'requested', + "decided_by" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "decided_at" timestamptz, + "posted_by" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "posted_at" timestamptz, + "post_url" text, + "decline_reason" text, + "prepared_text" text, + "prepared_image_key" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "broadcast_channels_unique_channel_per_request" + UNIQUE ("broadcast_request_id", "channel") +);--> statement-breakpoint + +CREATE INDEX "broadcast_channels_status_idx" + ON "broadcast_channels" ("status", "channel") + WHERE "status" IN ('approved', 'posted'); +``` + +- [ ] **Step 2: Update the migrations journal** + +Open `packages/api/migrations/meta/_journal.json`. After the `0019_groups_publishable` entry (the last one), append entries 20, 21, 22. The 0020 and 0021 migrations were applied via prior PRs but their journal entries are missing — add them too. + +The final 3 `entries` rows should be: + +```json + { + "idx": 20, + "version": "7", + "when": 1779043200000, + "tag": "0020_organizations_public_profile", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1779129600000, + "tag": "0021_profile_slack_username", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1779216000000, + "tag": "0022_artifact_subsystem", + "breakpoints": true + } +``` + +(Watch the trailing comma — the existing last entry no longer has a closing-brace-only line; insert before the `]`.) + +- [ ] **Step 3: Apply the migration locally** + +```bash +cd packages/api +DATABASE_URL="$DATABASE_URL" npm run db:apply -- 0022_artifact_subsystem +cd ../.. +``` + +Expected: `Migration 0022_artifact_subsystem applied`. If `db:apply` doesn't exist for individual migrations, run `npm run db:migrate` from the api workspace and let Drizzle apply pending migrations. + +- [ ] **Step 4: Verify all the new tables and columns exist** + +```bash +DATABASE_URL="$DATABASE_URL" node -e " +const { neon } = require('@neondatabase/serverless'); +const sql = neon(process.env.DATABASE_URL); +(async () => { + const tables = await sql\` + SELECT table_name FROM information_schema.tables + WHERE table_schema='public' + AND table_name IN ('announcements','forms','form_submissions', + 'artifact_reviews','artifact_comments', + 'broadcast_requests','broadcast_channels') + ORDER BY table_name + \`; + console.log('new tables:', tables.map(t => t.table_name)); + const eventCols = await sql\` + SELECT column_name FROM information_schema.columns + WHERE table_name='events' + AND column_name IN ('status','revision','author_id','scope','host_group_id','host_org_id','external_url','thumbnail_key') + ORDER BY column_name + \`; + console.log('new events columns:', eventCols.map(c => c.column_name)); +})(); +" +``` + +Expected output: +``` +new tables: [ 'announcements', 'artifact_comments', 'artifact_reviews', 'broadcast_channels', 'broadcast_requests', 'form_submissions', 'forms' ] +new events columns: [ 'author_id', 'external_url', 'host_group_id', 'host_org_id', 'revision', 'scope', 'status', 'thumbnail_key' ] +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/api/migrations/0022_artifact_subsystem.sql packages/api/migrations/meta/_journal.json +git commit -m "feat(db): add artifact subsystem schema (events extension + announcements + forms + polymorphic tables)" +``` + +--- + +## Task 2: Drizzle schema — shared enums + +**Files:** +- Modify: `packages/api/src/db/schema/enums.ts` + +- [ ] **Step 1: Add the new pg enums** + +Open `packages/api/src/db/schema/enums.ts`. After the existing enums (eventType, eventAttendanceRole, leadershipPositionType, eventCommitteeLevel, etc.), append: + +```ts +export const artifactStatus = pgEnum("artifact_status", [ + "draft", + "in_review", + "changes_requested", + "rejected", + "published", + "cancelled", + "completed", + "expired", + "closed", + "archived", +]); + +export const artifactScope = pgEnum("artifact_scope", [ + "public", + "community", + "group", + "staff_only", +]); + +export const artifactReviewDecision = pgEnum("artifact_review_decision", [ + "approve", + "reject", + "request_changes", +]); + +export const broadcastChannelEnum = pgEnum("broadcast_channel", [ + "site_banner", + "workspace_chat", + "newsletter", + "twitter_x", + "bluesky", + "mastodon", + "linkedin", +]); + +export const broadcastChannelStatus = pgEnum("broadcast_channel_status", [ + "requested", + "approved", + "declined", + "posted", +]); + +export const artifactEntityType = pgEnum("artifact_entity_type", [ + "event", + "announcement", + "form", + "group", +]); +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. (The enums are isolated additions; nothing else needs to change yet.) + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/db/schema/enums.ts +git commit -m "feat(db): add Drizzle enums for artifact subsystem" +``` + +--- + +## Task 3: Drizzle schema — extend events + +**Files:** +- Modify: `packages/api/src/db/schema/events.ts` + +- [ ] **Step 1: Add the new columns to the events table definition** + +Open `packages/api/src/db/schema/events.ts`. Update the imports: + +```ts +import { + boolean, + date, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { + artifactScope, + artifactStatus, + eventAttendanceRole, + eventType, +} from "./enums"; +import { groups } from "./groups"; +import { organizations } from "./vocab"; +import { users } from "./users"; +``` + +Replace the `events` table definition with: + +```ts +export const events = pgTable( + "events", + { + id: uuid("id").primaryKey().defaultRandom(), + slug: text("slug").notNull().unique(), + name: text("name").notNull(), + type: eventType("type").notNull(), + startDate: date("start_date").notNull(), + endDate: date("end_date"), + location: text("location"), + url: text("url"), + description: text("description"), + // Artifact-subsystem columns (added in migration 0022) + status: artifactStatus("status").notNull().default("draft"), + revision: integer("revision").notNull().default(1), + authorId: uuid("author_id").references(() => users.id, { + onDelete: "set null", + }), + scope: artifactScope("scope").notNull().default("community"), + hostGroupId: uuid("host_group_id").references(() => groups.id, { + onDelete: "set null", + }), + hostOrgId: uuid("host_org_id").references(() => organizations.id, { + onDelete: "set null", + }), + externalUrl: text("external_url"), + thumbnailKey: text("thumbnail_key"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [ + index("events_start_date_idx").on(t.startDate), + index("events_type_idx").on(t.type), + index("events_status_idx").on(t.status, t.createdAt), + ] +); +``` + +(The `boolean` import is not used in this file but added for consistency with sibling schema modules — remove if your linter objects.) + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/db/schema/events.ts +git commit -m "feat(db): extend events schema with artifact columns" +``` + +--- + +## Task 4: Drizzle schema — announcements + +**Files:** +- Create: `packages/api/src/db/schema/announcements.ts` + +- [ ] **Step 1: Create the announcements schema module** + +Write `packages/api/src/db/schema/announcements.ts`: + +```ts +import { relations } from "drizzle-orm"; +import { + index, + integer, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { artifactScope, artifactStatus } from "./enums"; +import { groups } from "./groups"; +import { organizations } from "./vocab"; +import { users } from "./users"; + +export const announcements = pgTable( + "announcements", + { + id: uuid("id").primaryKey().defaultRandom(), + status: artifactStatus("status").notNull().default("draft"), + revision: integer("revision").notNull().default(1), + authorId: uuid("author_id").references(() => users.id, { + onDelete: "set null", + }), + scope: artifactScope("scope").notNull().default("community"), + hostGroupId: uuid("host_group_id").references(() => groups.id, { + onDelete: "set null", + }), + hostOrgId: uuid("host_org_id").references(() => organizations.id, { + onDelete: "set null", + }), + title: text("title").notNull(), + body: text("body").notNull(), + linkUrl: text("link_url"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + thumbnailKey: text("thumbnail_key"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [index("announcements_status_idx").on(t.status, t.createdAt)] +); + +export const announcementsRelations = relations(announcements, ({ one }) => ({ + author: one(users, { + fields: [announcements.authorId], + references: [users.id], + }), + hostGroup: one(groups, { + fields: [announcements.hostGroupId], + references: [groups.id], + }), + hostOrg: one(organizations, { + fields: [announcements.hostOrgId], + references: [organizations.id], + }), +})); +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/db/schema/announcements.ts +git commit -m "feat(db): add announcements Drizzle schema" +``` + +--- + +## Task 5: Drizzle schema — forms + form_submissions + +**Files:** +- Create: `packages/api/src/db/schema/forms.ts` + +- [ ] **Step 1: Create the forms schema module** + +Write `packages/api/src/db/schema/forms.ts`: + +```ts +import { relations, sql } from "drizzle-orm"; +import { + boolean, + check, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { + artifactEntityType, + artifactScope, + artifactStatus, +} from "./enums"; +import { groups } from "./groups"; +import { users } from "./users"; + +export const forms = pgTable( + "forms", + { + id: uuid("id").primaryKey().defaultRandom(), + status: artifactStatus("status").notNull().default("draft"), + revision: integer("revision").notNull().default(1), + authorId: uuid("author_id").references(() => users.id, { + onDelete: "set null", + }), + scope: artifactScope("scope").notNull().default("community"), + hostGroupId: uuid("host_group_id").references(() => groups.id, { + onDelete: "set null", + }), + slug: text("slug").notNull().unique(), + title: text("title").notNull(), + description: text("description"), + schema: jsonb("schema").notNull(), + entityType: artifactEntityType("entity_type"), + entityId: uuid("entity_id"), + acceptsSubmissions: boolean("accepts_submissions").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [ + index("forms_status_idx").on(t.status, t.createdAt), + index("forms_entity_idx").on(t.entityType, t.entityId), + check( + "forms_entity_both_or_neither", + sql`(${t.entityType} IS NULL) = (${t.entityId} IS NULL)` + ), + ] +); + +export const formSubmissions = pgTable( + "form_submissions", + { + id: uuid("id").primaryKey().defaultRandom(), + formId: uuid("form_id") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + formRevision: integer("form_revision").notNull(), + submitterUserId: uuid("submitter_user_id").references(() => users.id, { + onDelete: "set null", + }), + payload: jsonb("payload").notNull(), + submittedAt: timestamp("submitted_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [index("form_submissions_form_idx").on(t.formId, t.submittedAt)] +); + +export const formsRelations = relations(forms, ({ many, one }) => ({ + submissions: many(formSubmissions), + author: one(users, { + fields: [forms.authorId], + references: [users.id], + }), +})); + +export const formSubmissionsRelations = relations(formSubmissions, ({ one }) => ({ + form: one(forms, { + fields: [formSubmissions.formId], + references: [forms.id], + }), +})); +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/db/schema/forms.ts +git commit -m "feat(db): add forms + form_submissions Drizzle schema" +``` + +--- + +## Task 6: Drizzle schema — polymorphic artifact tables + +**Files:** +- Create: `packages/api/src/db/schema/artifacts.ts` + +- [ ] **Step 1: Create the artifacts schema module** + +Write `packages/api/src/db/schema/artifacts.ts`: + +```ts +import { + index, + integer, + pgTable, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; +import { + artifactEntityType, + artifactReviewDecision, + broadcastChannelEnum, + broadcastChannelStatus, +} from "./enums"; +import { users } from "./users"; + +export const artifactReviews = pgTable( + "artifact_reviews", + { + id: uuid("id").primaryKey().defaultRandom(), + entityType: artifactEntityType("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + entityRevision: integer("entity_revision").notNull(), + reviewerId: uuid("reviewer_id") + .notNull() + .references(() => users.id, { onDelete: "restrict" }), + decision: artifactReviewDecision("decision").notNull(), + comment: text("comment"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("artifact_reviews_entity_idx").on( + t.entityType, + t.entityId, + t.entityRevision + ), + ] +); + +export const artifactComments = pgTable( + "artifact_comments", + { + id: uuid("id").primaryKey().defaultRandom(), + entityType: artifactEntityType("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + authorId: uuid("author_id") + .notNull() + .references(() => users.id, { onDelete: "restrict" }), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("artifact_comments_entity_idx").on( + t.entityType, + t.entityId, + t.createdAt + ), + ] +); + +export const broadcastRequests = pgTable( + "broadcast_requests", + { + id: uuid("id").primaryKey().defaultRandom(), + entityType: artifactEntityType("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + createdBy: uuid("created_by") + .notNull() + .references(() => users.id, { onDelete: "restrict" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + unique("broadcast_requests_unique_per_artifact").on(t.entityType, t.entityId), + ] +); + +export const broadcastChannels = pgTable( + "broadcast_channels", + { + id: uuid("id").primaryKey().defaultRandom(), + broadcastRequestId: uuid("broadcast_request_id") + .notNull() + .references(() => broadcastRequests.id, { onDelete: "cascade" }), + channel: broadcastChannelEnum("channel").notNull(), + status: broadcastChannelStatus("status").notNull().default("requested"), + decidedBy: uuid("decided_by").references(() => users.id, { + onDelete: "set null", + }), + decidedAt: timestamp("decided_at", { withTimezone: true }), + postedBy: uuid("posted_by").references(() => users.id, { + onDelete: "set null", + }), + postedAt: timestamp("posted_at", { withTimezone: true }), + postUrl: text("post_url"), + declineReason: text("decline_reason"), + preparedText: text("prepared_text"), + preparedImageKey: text("prepared_image_key"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + unique("broadcast_channels_unique_channel_per_request").on( + t.broadcastRequestId, + t.channel + ), + index("broadcast_channels_status_idx").on(t.status, t.channel), + ] +); +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/db/schema/artifacts.ts +git commit -m "feat(db): add polymorphic artifact tables (reviews, comments, broadcasts)" +``` + +--- + +## Task 7: Register schema modules in the barrel file + +**Files:** +- Modify: `packages/api/src/db/schema/index.ts` + +- [ ] **Step 1: Add the new module exports** + +Open `packages/api/src/db/schema/index.ts`. Add lines for the three new modules. The file already re-exports the existing modules; append: + +```ts +export * from "./announcements"; +export * from "./forms"; +export * from "./artifacts"; +``` + +(Order doesn't matter for re-exports; place them next to similarly-shaped modules.) + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/db/schema/index.ts +git commit -m "feat(db): register new schema modules in barrel" +``` + +--- + +## Task 8: Lifecycle library — types module + +**Files:** +- Create: `packages/api/src/lib/lifecycle/types.ts` + +- [ ] **Step 1: Create the types module** + +Write `packages/api/src/lib/lifecycle/types.ts`: + +```ts +/** + * Shared types for the artifact lifecycle library. + * + * Three artifact types share a status machine; each only uses the subset + * of states that makes sense. See spec §2. + */ + +export type ArtifactStatus = + | "draft" + | "in_review" + | "changes_requested" + | "rejected" + | "published" + | "cancelled" + | "completed" + | "expired" + | "closed" + | "archived"; + +export type ArtifactScope = "public" | "community" | "group" | "staff_only"; + +export type ArtifactEntityType = "event" | "announcement" | "form" | "group"; + +export type ReviewDecision = "approve" | "reject" | "request_changes"; + +/** + * Transitions an actor can request via the API. Internal-only transitions + * (the system-driven ones like auto-`completed` / auto-`expired`) are + * intentionally not in this set; they're computed by `effectiveStatus`. + */ +export type LifecycleAction = + | "submit_for_review" + | "request_changes" + | "reject" + | "approve" + | "cancel" + | "archive" + | "close" + | "publish"; // synthetic — emitted by applyTransition when the 2nd approval lands + +/** + * Inputs an applyTransition call needs at the row level. The lifecycle + * library doesn't know which concrete table the artifact lives in; the + * caller passes the resolved row + the entity_type. + */ +export interface ArtifactSnapshot { + id: string; + entityType: ArtifactEntityType; + status: ArtifactStatus; + revision: number; + authorId: string | null; + /** end_date for events, expires_at for announcements, undefined for forms */ + effectiveStatusInputs?: { + endDate?: string | null; + expiresAt?: Date | null; + }; +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/lib/lifecycle/types.ts +git commit -m "feat(api): add lifecycle library types" +``` + +--- + +## Task 9: Lifecycle library — valid transitions table + +**Files:** +- Create: `packages/api/src/lib/lifecycle/transitions.ts` +- Create: `packages/api/src/lib/lifecycle/transitions.test.ts` + +- [ ] **Step 1: Write the failing test** + +Write `packages/api/src/lib/lifecycle/transitions.test.ts`: + +```ts +import { describe, expect, test } from "vitest"; +import { isValidTransition } from "./transitions"; + +describe("isValidTransition", () => { + test("draft → submit_for_review is valid for all artifact types", () => { + for (const t of ["event", "announcement", "form"] as const) { + expect(isValidTransition(t, "draft", "submit_for_review")).toBe(true); + } + }); + + test("draft → approve is invalid (must submit_for_review first)", () => { + expect(isValidTransition("event", "draft", "approve")).toBe(false); + }); + + test("in_review → approve / reject / request_changes are valid", () => { + expect(isValidTransition("event", "in_review", "approve")).toBe(true); + expect(isValidTransition("event", "in_review", "reject")).toBe(true); + expect(isValidTransition("event", "in_review", "request_changes")).toBe(true); + }); + + test("changes_requested → submit_for_review (resubmit) is valid", () => { + expect(isValidTransition("event", "changes_requested", "submit_for_review")).toBe(true); + }); + + test("rejected is terminal", () => { + expect(isValidTransition("event", "rejected", "submit_for_review")).toBe(false); + expect(isValidTransition("event", "rejected", "approve")).toBe(false); + }); + + test("published → cancel valid for events only", () => { + expect(isValidTransition("event", "published", "cancel")).toBe(true); + expect(isValidTransition("announcement", "published", "cancel")).toBe(false); + expect(isValidTransition("form", "published", "cancel")).toBe(false); + }); + + test("published → close valid for forms only", () => { + expect(isValidTransition("form", "published", "close")).toBe(true); + expect(isValidTransition("event", "published", "close")).toBe(false); + expect(isValidTransition("announcement", "published", "close")).toBe(false); + }); + + test("published → archive valid for all types", () => { + expect(isValidTransition("event", "published", "archive")).toBe(true); + expect(isValidTransition("announcement", "published", "archive")).toBe(true); + expect(isValidTransition("form", "published", "archive")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify it fails with "transitions not defined"** + +```bash +cd packages/api && npx vitest run src/lib/lifecycle/transitions.test.ts +``` + +Expected: FAIL with "Cannot find module './transitions'" or similar. + +- [ ] **Step 3: Implement transitions.ts** + +Write `packages/api/src/lib/lifecycle/transitions.ts`: + +```ts +import type { ArtifactEntityType, ArtifactStatus, LifecycleAction } from "./types"; + +/** + * Valid transitions: (entity type, current status) → set of allowed actions. + * + * Reject and request_changes are single-reviewer; approve is the only + * action that requires the 2-vote tally to actually move state to + * `published`. Cancel/archive/close are post-publish administrative + * actions and only valid from `published`. + */ +type TransitionMap = Partial>>; + +const SHARED_TRANSITIONS: TransitionMap = { + draft: ["submit_for_review"], + in_review: ["approve", "reject", "request_changes"], + changes_requested: ["submit_for_review"], + // rejected: terminal + published: ["archive"], + // archived: terminal +}; + +const TYPE_OVERRIDES: Record = { + event: { + published: ["cancel", "archive"], + // completed: auto, terminal + // cancelled: terminal + }, + announcement: { + // expired: auto, terminal + }, + form: { + published: ["close", "archive"], + closed: ["archive"], + }, + group: { + // groups aren't a real artifact type for lifecycle purposes — included + // in the entity-type enum because comments/reviews can attach to them + // in future. No transitions defined here. + }, +}; + +export function isValidTransition( + entityType: ArtifactEntityType, + currentStatus: ArtifactStatus, + action: LifecycleAction +): boolean { + const typeOverride = TYPE_OVERRIDES[entityType][currentStatus]; + if (typeOverride !== undefined) { + return typeOverride.includes(action); + } + const shared = SHARED_TRANSITIONS[currentStatus]; + return shared !== undefined && shared.includes(action); +} + +export function allowedActionsFor( + entityType: ArtifactEntityType, + currentStatus: ArtifactStatus +): ReadonlyArray { + return ( + TYPE_OVERRIDES[entityType][currentStatus] ?? + SHARED_TRANSITIONS[currentStatus] ?? + [] + ); +} +``` + +- [ ] **Step 4: Run the test, verify it passes** + +```bash +cd packages/api && npx vitest run src/lib/lifecycle/transitions.test.ts +``` + +Expected: PASS — 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/api/src/lib/lifecycle/transitions.ts packages/api/src/lib/lifecycle/transitions.test.ts +git commit -m "feat(api): lifecycle library — valid transitions table" +``` + +--- + +## Task 10: Lifecycle library — effectiveStatus (auto-completed / auto-expired) + +**Files:** +- Create: `packages/api/src/lib/lifecycle/effectiveStatus.ts` +- Create: `packages/api/src/lib/lifecycle/effectiveStatus.test.ts` + +- [ ] **Step 1: Write the failing test** + +Write `packages/api/src/lib/lifecycle/effectiveStatus.test.ts`: + +```ts +import { describe, expect, test } from "vitest"; +import { effectiveStatus } from "./effectiveStatus"; + +describe("effectiveStatus", () => { + test("returns stored status for draft / in_review (no auto-transition)", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "event", + status: "draft", + revision: 1, + authorId: null, + effectiveStatusInputs: { endDate: "2020-01-01" }, + }) + ).toBe("draft"); + }); + + test("published event past end_date auto-transitions to completed", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "event", + status: "published", + revision: 1, + authorId: null, + effectiveStatusInputs: { endDate: "2020-01-01" }, + }) + ).toBe("completed"); + }); + + test("published event with future end_date stays published", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "event", + status: "published", + revision: 1, + authorId: null, + effectiveStatusInputs: { endDate: "2099-12-31" }, + }) + ).toBe("published"); + }); + + test("published announcement past expires_at auto-transitions to expired", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "announcement", + status: "published", + revision: 1, + authorId: null, + effectiveStatusInputs: { expiresAt: new Date("2020-01-01") }, + }) + ).toBe("expired"); + }); + + test("published announcement with no expires_at stays published", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "announcement", + status: "published", + revision: 1, + authorId: null, + }) + ).toBe("published"); + }); + + test("forms never auto-transition", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "form", + status: "published", + revision: 1, + authorId: null, + }) + ).toBe("published"); + }); + + test("terminal states pass through unchanged", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "event", + status: "cancelled", + revision: 1, + authorId: null, + }) + ).toBe("cancelled"); + }); +}); +``` + +- [ ] **Step 2: Run, verify it fails** + +```bash +cd packages/api && npx vitest run src/lib/lifecycle/effectiveStatus.test.ts +``` + +Expected: FAIL (module missing). + +- [ ] **Step 3: Implement effectiveStatus.ts** + +Write `packages/api/src/lib/lifecycle/effectiveStatus.ts`: + +```ts +import type { ArtifactSnapshot, ArtifactStatus } from "./types"; + +/** + * Compute the effective status for an artifact at read time. + * + * Only `published` rows are subject to auto-transition: + * - events past `end_date` → `completed` + * - announcements past `expires_at` → `expired` + * + * Forms never auto-transition. The stored `status` is returned in every + * other case. + */ +export function effectiveStatus(snap: ArtifactSnapshot): ArtifactStatus { + if (snap.status !== "published") return snap.status; + const now = new Date(); + if (snap.entityType === "event") { + const endDate = snap.effectiveStatusInputs?.endDate; + if (endDate && new Date(endDate) < now) return "completed"; + } + if (snap.entityType === "announcement") { + const expiresAt = snap.effectiveStatusInputs?.expiresAt; + if (expiresAt && expiresAt < now) return "expired"; + } + return "published"; +} +``` + +- [ ] **Step 4: Run, verify it passes** + +```bash +cd packages/api && npx vitest run src/lib/lifecycle/effectiveStatus.test.ts +``` + +Expected: PASS — 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/api/src/lib/lifecycle/effectiveStatus.ts packages/api/src/lib/lifecycle/effectiveStatus.test.ts +git commit -m "feat(api): lifecycle library — effectiveStatus read-time auto-transitions" +``` + +--- + +## Task 11: Lifecycle library — approval counter + +**Files:** +- Create: `packages/api/src/lib/lifecycle/approvals.ts` +- Create: `packages/api/src/lib/lifecycle/approvals.test.ts` + +- [ ] **Step 1: Write the failing test (uses an in-memory DB stub)** + +Write `packages/api/src/lib/lifecycle/approvals.test.ts`: + +```ts +import { describe, expect, test } from "vitest"; +import { countValidApprovals } from "./approvals"; + +type Review = { + entityType: "event" | "announcement" | "form" | "group"; + entityId: string; + entityRevision: number; + reviewerId: string; + decision: "approve" | "reject" | "request_changes"; +}; + +/** + * Test helper: a fake DB that filters in-memory rows. Lets us test + * approval logic without spinning up Postgres. + */ +function fakeDb(reviews: Review[]) { + return { + async listApprovalsForRevision( + entityType: Review["entityType"], + entityId: string, + revision: number + ): Promise<{ reviewerId: string }[]> { + return reviews + .filter( + (r) => + r.entityType === entityType && + r.entityId === entityId && + r.entityRevision === revision && + r.decision === "approve" + ) + .map((r) => ({ reviewerId: r.reviewerId })); + }, + }; +} + +describe("countValidApprovals", () => { + test("returns 0 when no approvals exist", async () => { + const db = fakeDb([]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 1, + authorId: "author-1", + }); + expect(n).toBe(0); + }); + + test("counts distinct reviewers, excluding author", async () => { + const db = fakeDb([ + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "approve" }, + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r2", decision: "approve" }, + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "author-1", decision: "approve" }, // self-approve, excluded + ]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 1, + authorId: "author-1", + }); + expect(n).toBe(2); + }); + + test("a single reviewer approving twice still counts as 1", async () => { + const db = fakeDb([ + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "approve" }, + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "approve" }, + ]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 1, + authorId: "author-1", + }); + expect(n).toBe(1); + }); + + test("approvals on a different revision do not count", async () => { + const db = fakeDb([ + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "approve" }, + { entityType: "event", entityId: "e1", entityRevision: 2, reviewerId: "r2", decision: "approve" }, + ]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 2, + authorId: "author-1", + }); + expect(n).toBe(1); + }); + + test("reject and request_changes decisions are not approvals", async () => { + const db = fakeDb([ + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "reject" }, + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r2", decision: "request_changes" }, + ]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 1, + authorId: "author-1", + }); + expect(n).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run, verify it fails** + +```bash +cd packages/api && npx vitest run src/lib/lifecycle/approvals.test.ts +``` + +Expected: FAIL (module missing). + +- [ ] **Step 3: Implement approvals.ts** + +Write `packages/api/src/lib/lifecycle/approvals.ts`: + +```ts +import type { ArtifactEntityType } from "./types"; + +/** + * The minimal DB surface this module needs. Production callers pass a + * Drizzle-backed implementation; tests pass an in-memory fake. + */ +export interface ApprovalDb { + listApprovalsForRevision( + entityType: ArtifactEntityType, + entityId: string, + revision: number + ): Promise<{ reviewerId: string }[]>; +} + +export interface CountValidApprovalsInput { + entityType: ArtifactEntityType; + entityId: string; + revision: number; + /** Author id is excluded from the approval tally (self-promotion guard). */ + authorId: string | null; +} + +/** + * Count distinct reviewers (excluding the author) who have approved the + * current revision of the artifact. Publish requires count >= 2. + * + * Resubmits bump the artifact's revision, so this function is naturally + * scoped to the active revision via the revision parameter — older + * approvals are silently ignored. + */ +export async function countValidApprovals( + db: ApprovalDb, + input: CountValidApprovalsInput +): Promise { + const approvals = await db.listApprovalsForRevision( + input.entityType, + input.entityId, + input.revision + ); + const distinct = new Set(); + for (const row of approvals) { + if (row.reviewerId === input.authorId) continue; + distinct.add(row.reviewerId); + } + return distinct.size; +} + +export const PUBLISH_APPROVAL_THRESHOLD = 2; +``` + +- [ ] **Step 4: Run, verify it passes** + +```bash +cd packages/api && npx vitest run src/lib/lifecycle/approvals.test.ts +``` + +Expected: PASS — 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/api/src/lib/lifecycle/approvals.ts packages/api/src/lib/lifecycle/approvals.test.ts +git commit -m "feat(api): lifecycle library — approval counter with self-promotion guard" +``` + +--- + +## Task 12: Lifecycle library — applyTransition orchestrator + +**Files:** +- Create: `packages/api/src/lib/lifecycle/applyTransition.ts` +- Create: `packages/api/src/lib/lifecycle/applyTransition.test.ts` + +This is the atomic-action piece. It validates the requested transition, writes the review row when applicable, counts approvals to decide if the publish threshold is reached, and emits the appropriate audit verb. + +- [ ] **Step 1: Write the failing test** + +Write `packages/api/src/lib/lifecycle/applyTransition.test.ts`: + +```ts +import { describe, expect, test } from "vitest"; +import { applyTransition, type LifecycleDb } from "./applyTransition"; + +type ArtifactRow = { + id: string; + status: import("./types").ArtifactStatus; + revision: number; + authorId: string | null; +}; + +type AuditRow = { action: string; targetType: string; targetId: string; payload: unknown }; + +function makeDb(initial: ArtifactRow): LifecycleDb & { + events: Record; + reviews: import("./approvals").ApprovalDb["listApprovalsForRevision"] extends infer F + ? F extends (...args: infer A) => Promise + ? Array<{ entityRevision: number; reviewerId: string; decision: "approve" | "reject" | "request_changes" }> + : never + : never; + audits: AuditRow[]; +} { + const events: Record = { [initial.id]: { ...initial } }; + const reviews: Array<{ entityRevision: number; reviewerId: string; decision: "approve" | "reject" | "request_changes" }> = []; + const audits: AuditRow[] = []; + return { + events, + reviews, + audits, + async fetchArtifact(entityType, id) { + const row = events[id]; + if (!row) return null; + return { + id: row.id, + entityType, + status: row.status, + revision: row.revision, + authorId: row.authorId, + }; + }, + async insertReview({ entityType, entityId, entityRevision, reviewerId, decision, comment }) { + reviews.push({ entityRevision, reviewerId, decision }); + }, + async listApprovalsForRevision(entityType, entityId, revision) { + return reviews + .filter((r) => r.entityRevision === revision && r.decision === "approve") + .map((r) => ({ reviewerId: r.reviewerId })); + }, + async updateArtifactStatus({ entityType, entityId, status, bumpRevision }) { + const row = events[entityId]; + if (!row) throw new Error("artifact missing"); + row.status = status; + if (bumpRevision) row.revision += 1; + }, + async insertAudit({ action, targetType, targetId, payload }) { + audits.push({ action, targetType, targetId, payload }); + }, + }; +} + +describe("applyTransition", () => { + test("draft → submit_for_review moves to in_review and emits audit", async () => { + const db = makeDb({ id: "e1", status: "draft", revision: 1, authorId: "author-1" }); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "submit_for_review", + actorId: "author-1", + }); + expect(result.ok).toBe(true); + expect(db.events["e1"].status).toBe("in_review"); + expect(db.audits[0].action).toBe("events.submit_for_review"); + }); + + test("rejecting a draft is invalid (must be in_review)", async () => { + const db = makeDb({ id: "e1", status: "draft", revision: 1, authorId: "author-1" }); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "reject", + actorId: "reviewer-1", + comment: "no", + }); + expect(result.ok).toBe(false); + expect(result.error).toBe("invalid_transition"); + }); + + test("first approval keeps status at in_review (1 of 2)", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + expect(result.ok).toBe(true); + expect(db.events["e1"].status).toBe("in_review"); + expect(db.reviews).toHaveLength(1); + }); + + test("second distinct approval publishes the artifact and audits 'publish'", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + const second = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-2", + }); + expect(second.ok).toBe(true); + expect(db.events["e1"].status).toBe("published"); + expect(db.audits.map((a) => a.action)).toContain("events.publish"); + }); + + test("author cannot approve their own artifact", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "author-1", + }); + expect(result.ok).toBe(false); + expect(result.error).toBe("self_approval_forbidden"); + }); + + test("same reviewer approving twice does not publish", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + expect(db.events["e1"].status).toBe("in_review"); + }); + + test("request_changes moves to changes_requested and requires comment", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + const noComment = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "request_changes", + actorId: "reviewer-1", + }); + expect(noComment.ok).toBe(false); + expect(noComment.error).toBe("comment_required"); + + const withComment = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "request_changes", + actorId: "reviewer-1", + comment: "Please tighten the title", + }); + expect(withComment.ok).toBe(true); + expect(db.events["e1"].status).toBe("changes_requested"); + }); + + test("resubmit from changes_requested bumps revision and invalidates prior approvals", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + // First approval on revision 1 + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + // Send back + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "request_changes", + actorId: "reviewer-2", + comment: "edit", + }); + // Resubmit + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "submit_for_review", + actorId: "author-1", + }); + expect(db.events["e1"].revision).toBe(2); + expect(db.events["e1"].status).toBe("in_review"); + // A single approval on revision 2 should not publish (prior approval was on rev 1) + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + expect(result.ok).toBe(true); + expect(db.events["e1"].status).toBe("in_review"); // only 1 approval on rev 2 + }); +}); +``` + +- [ ] **Step 2: Run, verify it fails** + +```bash +cd packages/api && npx vitest run src/lib/lifecycle/applyTransition.test.ts +``` + +Expected: FAIL (module missing). + +- [ ] **Step 3: Implement applyTransition.ts** + +Write `packages/api/src/lib/lifecycle/applyTransition.ts`: + +```ts +import { + PUBLISH_APPROVAL_THRESHOLD, + countValidApprovals, + type ApprovalDb, +} from "./approvals"; +import { isValidTransition } from "./transitions"; +import type { + ArtifactEntityType, + ArtifactSnapshot, + ArtifactStatus, + LifecycleAction, + ReviewDecision, +} from "./types"; + +export interface LifecycleDb extends ApprovalDb { + fetchArtifact( + entityType: ArtifactEntityType, + id: string + ): Promise; + insertReview(args: { + entityType: ArtifactEntityType; + entityId: string; + entityRevision: number; + reviewerId: string; + decision: ReviewDecision; + comment: string | null; + }): Promise; + updateArtifactStatus(args: { + entityType: ArtifactEntityType; + entityId: string; + status: ArtifactStatus; + bumpRevision: boolean; + }): Promise; + insertAudit(args: { + action: string; + targetType: string; + targetId: string; + payload: Record | null; + }): Promise; +} + +export interface ApplyTransitionInput { + entityType: ArtifactEntityType; + entityId: string; + action: LifecycleAction; + actorId: string; + /** Required for request_changes and reject; optional for approve. */ + comment?: string; +} + +export type ApplyTransitionResult = + | { ok: true; newStatus: ArtifactStatus } + | { + ok: false; + error: + | "not_found" + | "invalid_transition" + | "comment_required" + | "self_approval_forbidden" + | "self_review_forbidden"; + }; + +/** + * Atomic transition: validates, persists, and audits in one call. + * + * Map of action → review-decision-side-effect: + * submit_for_review → no review row; sets in_review (bumps revision if from changes_requested) + * approve → review row (approve); status flips to published when threshold met + * reject → review row (reject); status flips to rejected immediately + * request_changes → review row (request_changes); status flips to changes_requested + * cancel → no review row; status flips to cancelled + * archive → no review row; status flips to archived + * close → no review row; status flips to closed + */ +export async function applyTransition( + db: LifecycleDb, + input: ApplyTransitionInput +): Promise { + const artifact = await db.fetchArtifact(input.entityType, input.entityId); + if (!artifact) return { ok: false, error: "not_found" }; + + if (!isValidTransition(input.entityType, artifact.status, input.action)) { + return { ok: false, error: "invalid_transition" }; + } + + // Review-decision actions guarded against self-review + if ( + (input.action === "approve" || + input.action === "reject" || + input.action === "request_changes") && + artifact.authorId === input.actorId + ) { + return { + ok: false, + error: + input.action === "approve" + ? "self_approval_forbidden" + : "self_review_forbidden", + }; + } + + if ( + (input.action === "request_changes" || input.action === "reject") && + !input.comment?.trim() + ) { + return { ok: false, error: "comment_required" }; + } + + // Resolve target status and side-effects + let newStatus: ArtifactStatus = artifact.status; + let bumpRevision = false; + let auditAction = ""; + let writeReview = false; + let reviewDecision: ReviewDecision | null = null; + + switch (input.action) { + case "submit_for_review": { + newStatus = "in_review"; + bumpRevision = artifact.status === "changes_requested"; + auditAction = verb(input.entityType, "submit_for_review"); + break; + } + case "request_changes": { + newStatus = "changes_requested"; + auditAction = verb(input.entityType, "request_changes"); + writeReview = true; + reviewDecision = "request_changes"; + break; + } + case "reject": { + newStatus = "rejected"; + auditAction = verb(input.entityType, "reject"); + writeReview = true; + reviewDecision = "reject"; + break; + } + case "approve": { + writeReview = true; + reviewDecision = "approve"; + // Insert the approval first, then count. + await db.insertReview({ + entityType: input.entityType, + entityId: input.entityId, + entityRevision: artifact.revision, + reviewerId: input.actorId, + decision: "approve", + comment: input.comment?.trim() || null, + }); + const count = await countValidApprovals(db, { + entityType: input.entityType, + entityId: input.entityId, + revision: artifact.revision, + authorId: artifact.authorId, + }); + if (count >= PUBLISH_APPROVAL_THRESHOLD) { + await db.updateArtifactStatus({ + entityType: input.entityType, + entityId: input.entityId, + status: "published", + bumpRevision: false, + }); + await db.insertAudit({ + action: verb(input.entityType, "publish"), + targetType: tableName(input.entityType), + targetId: input.entityId, + payload: { approvalCount: count, revision: artifact.revision }, + }); + return { ok: true, newStatus: "published" }; + } + // First approval: status unchanged, audit only + await db.insertAudit({ + action: verb(input.entityType, "approve"), + targetType: tableName(input.entityType), + targetId: input.entityId, + payload: { approvalCount: count, revision: artifact.revision }, + }); + return { ok: true, newStatus: artifact.status }; + } + case "cancel": { + newStatus = "cancelled"; + auditAction = verb(input.entityType, "cancel"); + break; + } + case "archive": { + newStatus = "archived"; + auditAction = verb(input.entityType, "archive"); + break; + } + case "close": { + newStatus = "closed"; + auditAction = verb(input.entityType, "close"); + break; + } + case "publish": { + // Not directly addressable — `applyTransition` emits publish itself + // when 2 approvals land. Treat as invalid if called explicitly. + return { ok: false, error: "invalid_transition" }; + } + } + + if (writeReview && reviewDecision) { + await db.insertReview({ + entityType: input.entityType, + entityId: input.entityId, + entityRevision: artifact.revision, + reviewerId: input.actorId, + decision: reviewDecision, + comment: input.comment?.trim() || null, + }); + } + + await db.updateArtifactStatus({ + entityType: input.entityType, + entityId: input.entityId, + status: newStatus, + bumpRevision, + }); + await db.insertAudit({ + action: auditAction, + targetType: tableName(input.entityType), + targetId: input.entityId, + payload: input.comment ? { comment: input.comment.trim() } : null, + }); + + return { ok: true, newStatus }; +} + +function verb(entityType: ArtifactEntityType, action: string): string { + return `${tableName(entityType)}.${action}`; +} + +function tableName(entityType: ArtifactEntityType): string { + switch (entityType) { + case "event": + return "events"; + case "announcement": + return "announcements"; + case "form": + return "forms"; + case "group": + return "groups"; + } +} +``` + +- [ ] **Step 4: Run, verify it passes** + +```bash +cd packages/api && npx vitest run src/lib/lifecycle/applyTransition.test.ts +``` + +Expected: PASS — 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/api/src/lib/lifecycle/applyTransition.ts packages/api/src/lib/lifecycle/applyTransition.test.ts +git commit -m "feat(api): lifecycle library — applyTransition orchestrator" +``` + +--- + +## Task 13: Lifecycle library — barrel export + +**Files:** +- Create: `packages/api/src/lib/lifecycle/index.ts` + +- [ ] **Step 1: Create the barrel** + +Write `packages/api/src/lib/lifecycle/index.ts`: + +```ts +export * from "./types"; +export * from "./transitions"; +export * from "./effectiveStatus"; +export * from "./approvals"; +export * from "./applyTransition"; +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/lib/lifecycle/index.ts +git commit -m "feat(api): lifecycle library — barrel export" +``` + +--- + +## Task 14: Policy — canEditArtifact + +**Files:** +- Create: `packages/api/src/lib/policies/canEditArtifact.ts` +- Modify: `packages/api/src/lib/policies/index.ts` +- Modify: `packages/api/src/lib/policies/policies.test.ts` + +- [ ] **Step 1: Append failing tests to the existing policies test file** + +Open `packages/api/src/lib/policies/policies.test.ts` and append: + +```ts +import { canEditArtifact } from "./canEditArtifact"; + +const memberActor = (id = "u-member") => ({ + user: { id, memberId: "m", email: "e", role: "member" as const }, + systemTier: 0 as const, + leadershipPositions: [], + chairedGroupIds: new Set(), + chairedEventIds: new Set(), +}); + +const staffActor = (id = "u-staff") => ({ + ...memberActor(id), + user: { ...memberActor(id).user, role: "staff" as const }, + systemTier: 1 as const, +}); + +describe("canEditArtifact", () => { + test("staff can edit any artifact in any state", () => { + expect( + canEditArtifact(staffActor(), { + entityType: "event", + entityId: "e", + status: "in_review", + authorId: "someone-else", + }) + ).toBe(true); + expect( + canEditArtifact(staffActor(), { + entityType: "event", + entityId: "e", + status: "published", + authorId: "someone-else", + }) + ).toBe(true); + }); + + test("author can edit their own draft", () => { + const a = memberActor("author-1"); + expect( + canEditArtifact(a, { + entityType: "event", + entityId: "e", + status: "draft", + authorId: "author-1", + }) + ).toBe(true); + }); + + test("author can edit their own changes_requested", () => { + const a = memberActor("author-1"); + expect( + canEditArtifact(a, { + entityType: "event", + entityId: "e", + status: "changes_requested", + authorId: "author-1", + }) + ).toBe(true); + }); + + test("author cannot edit their own in_review (locked while reviewers look)", () => { + const a = memberActor("author-1"); + expect( + canEditArtifact(a, { + entityType: "event", + entityId: "e", + status: "in_review", + authorId: "author-1", + }) + ).toBe(false); + }); + + test("non-author non-staff member cannot edit someone else's artifact", () => { + const a = memberActor("u-1"); + expect( + canEditArtifact(a, { + entityType: "event", + entityId: "e", + status: "draft", + authorId: "author-2", + }) + ).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run, verify it fails** + +```bash +cd packages/api && npx vitest run src/lib/policies/policies.test.ts +``` + +Expected: FAIL (module missing). + +- [ ] **Step 3: Implement canEditArtifact.ts** + +Write `packages/api/src/lib/policies/canEditArtifact.ts`: + +```ts +import type { ArtifactEntityType, ArtifactStatus } from "../lifecycle/types"; +import type { ActorContext } from "./types"; + +export const canEditArtifact = ( + a: ActorContext, + scope: { + entityType: ArtifactEntityType; + entityId: string; + status: ArtifactStatus; + authorId: string | null; + } +): boolean => { + if (a.systemTier >= 1) return true; + if (scope.authorId !== a.user.id) return false; + return scope.status === "draft" || scope.status === "changes_requested"; +}; +``` + +- [ ] **Step 4: Run, verify it passes** + +```bash +cd packages/api && npx vitest run src/lib/policies/policies.test.ts +``` + +Expected: PASS, including 5 new canEditArtifact tests. + +- [ ] **Step 5: Add to policies barrel** + +Open `packages/api/src/lib/policies/index.ts` and add the export line: + +```ts +export { canEditArtifact } from "./canEditArtifact"; +``` + +- [ ] **Step 6: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/api/src/lib/policies/canEditArtifact.ts packages/api/src/lib/policies/index.ts packages/api/src/lib/policies/policies.test.ts +git commit -m "feat(api): canEditArtifact policy (staff or author-on-draft/changes_requested)" +``` + +--- + +## Task 15: Policy — canReviewArtifact + +**Files:** +- Create: `packages/api/src/lib/policies/canReviewArtifact.ts` +- Modify: `packages/api/src/lib/policies/index.ts` +- Modify: `packages/api/src/lib/policies/policies.test.ts` + +- [ ] **Step 1: Append failing test to policies.test.ts** + +Append to `packages/api/src/lib/policies/policies.test.ts`: + +```ts +import { canReviewArtifact } from "./canReviewArtifact"; + +describe("canReviewArtifact", () => { + test("staff can review any artifact they didn't author", () => { + expect( + canReviewArtifact(staffActor("staff-1"), { authorId: "author-1" }) + ).toBe(true); + }); + + test("staff cannot review their own artifact (self-promotion guard)", () => { + expect( + canReviewArtifact(staffActor("staff-1"), { authorId: "staff-1" }) + ).toBe(false); + }); + + test("member cannot review (insufficient tier)", () => { + expect( + canReviewArtifact(memberActor("m-1"), { authorId: "author-1" }) + ).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run, verify it fails** + +```bash +cd packages/api && npx vitest run src/lib/policies/policies.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement canReviewArtifact.ts** + +Write `packages/api/src/lib/policies/canReviewArtifact.ts`: + +```ts +import type { ActorContext } from "./types"; + +export const canReviewArtifact = ( + a: ActorContext, + scope: { authorId: string | null } +): boolean => a.systemTier >= 1 && a.user.id !== scope.authorId; +``` + +- [ ] **Step 4: Run, verify it passes** + +```bash +cd packages/api && npx vitest run src/lib/policies/policies.test.ts +``` + +Expected: PASS — 3 new tests for canReviewArtifact. + +- [ ] **Step 5: Add to barrel** + +Open `packages/api/src/lib/policies/index.ts` and add: + +```ts +export { canReviewArtifact } from "./canReviewArtifact"; +``` + +- [ ] **Step 6: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/api/src/lib/policies/canReviewArtifact.ts packages/api/src/lib/policies/index.ts packages/api/src/lib/policies/policies.test.ts +git commit -m "feat(api): canReviewArtifact policy (staff + not-the-author)" +``` + +--- + +## Task 16: Drizzle adapter for the LifecycleDb interface + +**Files:** +- Create: `packages/api/src/lib/lifecycle/drizzleAdapter.ts` + +This adapts the in-memory `LifecycleDb` interface to actual Drizzle queries. Routes use this adapter; tests use the in-memory fake. + +- [ ] **Step 1: Create the adapter** + +Write `packages/api/src/lib/lifecycle/drizzleAdapter.ts`: + +```ts +import { and, desc, eq } from "drizzle-orm"; +import type { NeonHttpDatabase } from "drizzle-orm/neon-http"; +import { announcements, artifactReviews, auditLog, events, forms } from "../../db/schema"; +import type * as schema from "../../db/schema"; +import type { + ApplyTransitionInput, + LifecycleDb, +} from "./applyTransition"; +import type { ArtifactEntityType, ArtifactStatus, ReviewDecision } from "./types"; + +type Db = NeonHttpDatabase; + +/** + * Production implementation of LifecycleDb backed by Drizzle. + * Each row-touching method maps to one or two SQL statements. + * + * The actor's role is needed when writing audit rows; we pass it + * separately because the lifecycle layer doesn't know about actor contexts. + */ +export function drizzleLifecycleDb( + db: Db, + actor: { id: string; role: "member" | "staff" | "super_admin" } +): LifecycleDb { + return { + async fetchArtifact(entityType, id) { + const table = artifactTable(entityType); + if (!table) return null; + const row = await db + .select({ + id: table.id, + status: table.status, + revision: table.revision, + authorId: table.authorId, + endDate: "endDate" in table ? table.endDate : undefined, + expiresAt: "expiresAt" in table ? table.expiresAt : undefined, + } as Record) + .from(table) + .where(eq(table.id, id)) + .limit(1) + .then((r) => r[0]); + if (!row) return null; + return { + id: row.id as string, + entityType, + status: row.status as ArtifactStatus, + revision: row.revision as number, + authorId: (row.authorId as string | null) ?? null, + effectiveStatusInputs: { + endDate: (row.endDate as string | null) ?? null, + expiresAt: (row.expiresAt as Date | null) ?? null, + }, + }; + }, + + async insertReview({ + entityType, + entityId, + entityRevision, + reviewerId, + decision, + comment, + }) { + await db.insert(artifactReviews).values({ + entityType, + entityId, + entityRevision, + reviewerId, + decision: decision as ReviewDecision, + comment, + }); + }, + + async listApprovalsForRevision(entityType, entityId, revision) { + const rows = await db + .select({ reviewerId: artifactReviews.reviewerId }) + .from(artifactReviews) + .where( + and( + eq(artifactReviews.entityType, entityType), + eq(artifactReviews.entityId, entityId), + eq(artifactReviews.entityRevision, revision), + eq(artifactReviews.decision, "approve") + ) + ); + return rows; + }, + + async updateArtifactStatus({ entityType, entityId, status, bumpRevision }) { + const table = artifactTable(entityType); + if (!table) throw new Error(`unsupported entity type: ${entityType}`); + const patch: Record = { + status, + updatedAt: new Date(), + }; + if (bumpRevision) { + // We do revision = revision + 1 in a follow-up update to keep + // this method's signature simple. Two updates in one logical + // transition is fine — the route layer wraps the whole call in + // an outer transaction. + const current = await db + .select({ revision: table.revision }) + .from(table) + .where(eq(table.id, entityId)) + .limit(1) + .then((r) => r[0]); + if (current) patch.revision = current.revision + 1; + } + await db.update(table).set(patch).where(eq(table.id, entityId)); + }, + + async insertAudit({ action, targetType, targetId, payload }) { + await db.insert(auditLog).values({ + actorId: actor.id, + actorRole: actor.role, + action, + targetType, + targetId, + payload, + }); + }, + }; +} + +function artifactTable(entityType: ArtifactEntityType) { + switch (entityType) { + case "event": + return events; + case "announcement": + return announcements; + case "form": + return forms; + case "group": + return null; + } +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. (The strict types on `select()` may need a small adjustment; if Drizzle complains about the dynamic table selection, fall back to a union return type or one branch per entity type.) + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/lib/lifecycle/drizzleAdapter.ts +git commit -m "feat(api): Drizzle adapter for LifecycleDb interface" +``` + +--- + +## Task 17: Comment helpers + +**Files:** +- Create: `packages/api/src/lib/artifacts/comments.ts` +- Create: `packages/api/src/lib/artifacts/comments.test.ts` + +- [ ] **Step 1: Write the failing test** + +Write `packages/api/src/lib/artifacts/comments.test.ts`: + +```ts +import { describe, expect, test } from "vitest"; +import { sanitizeCommentBody, COMMENT_MAX_LEN } from "./comments"; + +describe("sanitizeCommentBody", () => { + test("trims whitespace", () => { + expect(sanitizeCommentBody(" hello ").body).toBe("hello"); + }); + + test("rejects empty bodies", () => { + expect(sanitizeCommentBody("").ok).toBe(false); + expect(sanitizeCommentBody(" ").ok).toBe(false); + }); + + test("rejects bodies over the max length", () => { + const long = "x".repeat(COMMENT_MAX_LEN + 1); + expect(sanitizeCommentBody(long).ok).toBe(false); + }); + + test("accepts a normal comment", () => { + const r = sanitizeCommentBody("This needs a clearer title."); + expect(r.ok).toBe(true); + if (r.ok) expect(r.body).toBe("This needs a clearer title."); + }); +}); +``` + +- [ ] **Step 2: Run, verify it fails** + +```bash +cd packages/api && npx vitest run src/lib/artifacts/comments.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement comments.ts** + +Write `packages/api/src/lib/artifacts/comments.ts`: + +```ts +export const COMMENT_MAX_LEN = 4000; + +export type SanitizedComment = + | { ok: true; body: string } + | { ok: false; error: "empty" | "too_long" }; + +/** + * Normalize a comment body before insertion. Trims whitespace, rejects + * empties, enforces the max-length cap. Doesn't touch HTML — comments + * are rendered as plain text on the admin UI. + */ +export function sanitizeCommentBody(raw: string): SanitizedComment { + const trimmed = raw.trim(); + if (!trimmed) return { ok: false, error: "empty" }; + if (trimmed.length > COMMENT_MAX_LEN) return { ok: false, error: "too_long" }; + return { ok: true, body: trimmed }; +} +``` + +- [ ] **Step 4: Run, verify it passes** + +```bash +cd packages/api && npx vitest run src/lib/artifacts/comments.test.ts +``` + +Expected: PASS — 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/api/src/lib/artifacts/comments.ts packages/api/src/lib/artifacts/comments.test.ts +git commit -m "feat(api): artifact comments sanitizer" +``` + +--- + +## Task 18: Unified queue endpoint — `GET /admin/queue` + +**Files:** +- Create: `packages/api/src/routes/admin/queue/index.ts` +- Create: `packages/api/src/routes/admin/queue/index.test.ts` +- Modify: `packages/api/src/routes/admin/index.ts` (mount the sub-app) + +- [ ] **Step 1: Write the failing test** + +Write `packages/api/src/routes/admin/queue/index.test.ts`: + +```ts +import { describe, expect, test, beforeAll, afterAll } from "vitest"; +import { testApp, makeStaffActor, makeMemberActor, seedArtifacts } from "../../../test/helpers"; + +let cleanup: () => Promise; + +beforeAll(async () => { + cleanup = await seedArtifacts({ + events: [ + { id: "ev-1", status: "in_review", revision: 1, authorId: "u-author", name: "Test Event", scope: "community", startDate: "2026-06-01" }, + { id: "ev-2", status: "published", revision: 1, authorId: "u-author", name: "Already Published", scope: "public", startDate: "2026-07-01" }, + ], + announcements: [ + { id: "an-1", status: "in_review", revision: 1, authorId: "u-author", title: "Heads up", body: "..." }, + ], + forms: [ + { id: "f-1", status: "draft", revision: 1, authorId: "u-author", slug: "x", title: "x", schema: {} }, + ], + }); +}); + +afterAll(async () => { + await cleanup(); +}); + +describe("GET /admin/queue", () => { + test("requires staff actor", async () => { + const res = await testApp.request("/admin/queue", { + headers: { Authorization: makeMemberActor("u-member") }, + }); + expect(res.status).toBe(403); + }); + + test("returns in_review artifacts across all three types", async () => { + const res = await testApp.request("/admin/queue", { + headers: { Authorization: makeStaffActor("u-staff") }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { rows: Array<{ id: string; entityType: string; status: string }> }; + const ids = body.rows.map((r) => `${r.entityType}:${r.id}`).sort(); + expect(ids).toEqual(["announcement:an-1", "event:ev-1"]); + }); + + test("supports filtering by entity type", async () => { + const res = await testApp.request("/admin/queue?type=event", { + headers: { Authorization: makeStaffActor("u-staff") }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { rows: Array<{ entityType: string }> }; + expect(body.rows.every((r) => r.entityType === "event")).toBe(true); + }); +}); +``` + +**Note:** the `packages/api/src/test/helpers.ts` referenced here is created in Task 20. If your local test runner picks this file up early, that's fine — vitest collects but doesn't execute until the explicit run. + +- [ ] **Step 2: Implement the queue route** + +Write `packages/api/src/routes/admin/queue/index.ts`: + +```ts +import { Hono } from "hono"; +import { sql } from "drizzle-orm"; +import { createDb } from "../../../db"; +import type { AppEnv } from "../../../types"; + +export const adminQueueRoute = new Hono(); + +/** + * GET /admin/queue + * + * Returns all in_review artifacts across events, announcements, forms + * via UNION ALL. Filters: type, scope, age_days, author_role. + */ +adminQueueRoute.get("/", async (c) => { + if (!c.env.DATABASE_URL) { + return c.json({ ok: false, error: "internal" }, 500); + } + const actor = c.get("actor"); + if (!actor || actor.systemTier < 1) { + return c.json({ ok: false, error: "forbidden" }, 403); + } + const db = createDb(c.env.DATABASE_URL); + + const typeFilter = c.req.query("type"); + const scopeFilter = c.req.query("scope"); + + // UNION ALL across the three artifact tables; cast columns to a common + // shape with a literal entity_type tag. + const rows = await db.execute(sql` + SELECT entity_type, id, title, status, revision, scope, author_id, host_group_id, host_org_id, created_at + FROM ( + SELECT 'event'::text AS entity_type, id, name AS title, status::text, revision, scope::text, author_id, host_group_id, host_org_id, created_at + FROM events WHERE deleted_at IS NULL AND status = 'in_review' + UNION ALL + SELECT 'announcement'::text, id, title, status::text, revision, scope::text, author_id, host_group_id, NULL::uuid AS host_org_id, created_at + FROM announcements WHERE deleted_at IS NULL AND status = 'in_review' + UNION ALL + SELECT 'form'::text, id, title, status::text, revision, scope::text, author_id, host_group_id, NULL::uuid, created_at + FROM forms WHERE deleted_at IS NULL AND status = 'in_review' + ) q + WHERE (${typeFilter ?? null}::text IS NULL OR entity_type = ${typeFilter ?? null}::text) + AND (${scopeFilter ?? null}::text IS NULL OR scope = ${scopeFilter ?? null}::text) + ORDER BY created_at ASC + LIMIT 200 + `); + + return c.json({ + ok: true, + rows: rows.rows.map((r: Record) => ({ + entityType: r.entity_type, + id: r.id, + title: r.title, + status: r.status, + revision: r.revision, + scope: r.scope, + authorId: r.author_id, + hostGroupId: r.host_group_id, + hostOrgId: r.host_org_id, + createdAt: r.created_at, + })), + }); +}); +``` + +- [ ] **Step 3: Mount the route in the admin sub-app** + +Open `packages/api/src/routes/admin/index.ts`. After the existing route mounts (groups, users, organizations, vocab), add: + +```ts +import { adminQueueRoute } from "./queue"; + +adminApp.route("/queue", adminQueueRoute); +``` + +- [ ] **Step 4: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Defer running the test until Task 20 lands the helpers** + +The vitest run for this test is part of Task 20. For now, just commit. + +- [ ] **Step 6: Commit** + +```bash +git add packages/api/src/routes/admin/queue/index.ts packages/api/src/routes/admin/queue/index.test.ts packages/api/src/routes/admin/index.ts +git commit -m "feat(api): GET /admin/queue (UNION ALL across in_review artifacts)" +``` + +--- + +## Task 19: Active-banner endpoint stub — `GET /announcements/active-banner` + +**Files:** +- Create: `packages/api/src/routes/announcements.ts` +- Modify: `packages/api/src/index.ts` (mount the route) + +- [ ] **Step 1: Create the route file** + +Write `packages/api/src/routes/announcements.ts`: + +```ts +import { Hono } from "hono"; +import { and, desc, eq, gt, isNull, or } from "drizzle-orm"; +import { createDb } from "../db"; +import { announcements, broadcastChannels, broadcastRequests } from "../db/schema"; +import type { AppEnv } from "../types"; + +export const announcementsRoute = new Hono(); + +/** + * GET /announcements/active-banner + * + * Returns at most one announcement: the most-recently-published row whose + * effective status is `published` (not expired) AND which has at least one + * broadcast_channels row with channel='site_banner' AND status='posted'. + * + * In v1, no announcements exist yet — the endpoint exists so the SPA's + * can mount without conditional code. Plan 3 wires the + * real query when announcements ship. + */ +announcementsRoute.get("/active-banner", async (c) => { + if (!c.env.DATABASE_URL) return c.json({ banner: null }); + const db = createDb(c.env.DATABASE_URL); + + const now = new Date(); + const row = await db + .select({ + id: announcements.id, + title: announcements.title, + body: announcements.body, + linkUrl: announcements.linkUrl, + expiresAt: announcements.expiresAt, + }) + .from(announcements) + .innerJoin( + broadcastRequests, + and( + eq(broadcastRequests.entityType, "announcement"), + eq(broadcastRequests.entityId, announcements.id) + ) + ) + .innerJoin( + broadcastChannels, + and( + eq(broadcastChannels.broadcastRequestId, broadcastRequests.id), + eq(broadcastChannels.channel, "site_banner"), + eq(broadcastChannels.status, "posted") + ) + ) + .where( + and( + eq(announcements.status, "published"), + isNull(announcements.deletedAt), + or(isNull(announcements.expiresAt), gt(announcements.expiresAt, now)) + ) + ) + .orderBy(desc(announcements.createdAt)) + .limit(1) + .then((r) => r[0]); + + if (!row) return c.json({ banner: null }); + return c.json({ + banner: { + id: row.id, + title: row.title, + body: row.body, + linkUrl: row.linkUrl, + }, + }); +}); +``` + +- [ ] **Step 2: Mount the route** + +Open `packages/api/src/index.ts` and add the mount alongside the other public routes: + +```ts +import { announcementsRoute } from "./routes/announcements"; + +app.route("/announcements", announcementsRoute); +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 4: Smoke test against the dev worker** + +Start the worker and curl the endpoint: + +```bash +cd packages/api && npm run dev & +sleep 2 +curl -s http://localhost:8787/announcements/active-banner +``` + +Expected: `{"banner":null}` (no announcements seeded yet). Kill the worker after verifying. + +- [ ] **Step 5: Commit** + +```bash +git add packages/api/src/routes/announcements.ts packages/api/src/index.ts +git commit -m "feat(api): GET /announcements/active-banner stub (returns null until Plan 3)" +``` + +--- + +## Task 20: Test helpers (db-touching integration tests) + +**Files:** +- Create: `packages/api/src/test/helpers.ts` + +- [ ] **Step 1: Create test helpers** + +Write `packages/api/src/test/helpers.ts`: + +```ts +import { neon } from "@neondatabase/serverless"; +import app from "../index"; + +/** + * Test helpers for integration tests that touch the real DB. + * + * Usage requires DATABASE_URL pointed at a test branch (a Neon branch + * cut from main is recommended; truncate-on-teardown is faster than + * recreate). The `seedArtifacts` helper returns a cleanup function + * that DELETEs the inserted rows. + * + * Auth: actors are stubbed via the same WorkOS-token-shaped Authorization + * header the production middleware expects. In tests we set an env flag + * (TEST_BYPASS_AUTH=1) that the middleware honors and parses the header + * value directly as the actor id. + */ + +export const testApp = app; + +export function makeStaffActor(userId: string): string { + return `test:staff:${userId}`; +} + +export function makeMemberActor(userId: string): string { + return `test:member:${userId}`; +} + +interface SeedInput { + events?: Array<{ + id: string; + status: string; + revision: number; + authorId: string; + name: string; + scope: string; + startDate: string; + endDate?: string; + }>; + announcements?: Array<{ + id: string; + status: string; + revision: number; + authorId: string; + title: string; + body: string; + expiresAt?: string; + }>; + forms?: Array<{ + id: string; + status: string; + revision: number; + authorId: string; + slug: string; + title: string; + schema: unknown; + }>; +} + +export async function seedArtifacts(input: SeedInput): Promise<() => Promise> { + const sql = neon(process.env.DATABASE_URL!); + const insertedEventIds = (input.events ?? []).map((e) => e.id); + const insertedAnnouncementIds = (input.announcements ?? []).map((a) => a.id); + const insertedFormIds = (input.forms ?? []).map((f) => f.id); + + for (const e of input.events ?? []) { + await sql` + INSERT INTO events (id, slug, name, type, start_date, end_date, status, revision, author_id, scope) + VALUES ( + ${e.id}, ${e.id + "-slug"}, ${e.name}, 'other', ${e.startDate}::date, ${e.endDate ?? null}::date, + ${e.status}::artifact_status, ${e.revision}, ${e.authorId}, ${e.scope}::artifact_scope + ) + `; + } + for (const a of input.announcements ?? []) { + await sql` + INSERT INTO announcements (id, status, revision, author_id, title, body, expires_at) + VALUES ( + ${a.id}, ${a.status}::artifact_status, ${a.revision}, ${a.authorId}, + ${a.title}, ${a.body}, ${a.expiresAt ?? null} + ) + `; + } + for (const f of input.forms ?? []) { + await sql` + INSERT INTO forms (id, slug, title, schema, status, revision, author_id) + VALUES ( + ${f.id}, ${f.slug}, ${f.title}, ${JSON.stringify(f.schema)}::jsonb, + ${f.status}::artifact_status, ${f.revision}, ${f.authorId} + ) + `; + } + + return async () => { + if (insertedEventIds.length) { + await sql`DELETE FROM events WHERE id = ANY(${insertedEventIds}::uuid[])`; + } + if (insertedAnnouncementIds.length) { + await sql`DELETE FROM announcements WHERE id = ANY(${insertedAnnouncementIds}::uuid[])`; + } + if (insertedFormIds.length) { + await sql`DELETE FROM forms WHERE id = ANY(${insertedFormIds}::uuid[])`; + } + }; +} +``` + +- [ ] **Step 2: Add a TEST_BYPASS_AUTH branch to the actor middleware** + +Open `packages/api/src/middleware/requireActorContext.ts` (or whichever file resolves the actor from the Authorization header — find it via `grep -rn "requireActorContext\|set('actor'" packages/api/src/middleware/`). Add at the very top of the resolver: + +```ts +// Test escape hatch: when TEST_BYPASS_AUTH=1, parse the Authorization +// header as `test::` and synthesize an actor directly. +if (c.env.TEST_BYPASS_AUTH === "1") { + const header = c.req.header("Authorization") ?? ""; + const match = header.match(/^test:(staff|member|super_admin):(.+)$/); + if (match) { + const role = match[1] as "staff" | "member" | "super_admin"; + const userId = match[2]; + c.set("actor", { + user: { id: userId, memberId: userId, email: `${userId}@test`, role }, + systemTier: role === "member" ? 0 : role === "staff" ? 1 : 2, + leadershipPositions: [], + chairedGroupIds: new Set(), + chairedEventIds: new Set(), + }); + await next(); + return; + } +} +``` + +- [ ] **Step 3: Run the queue route test** + +```bash +cd packages/api && DATABASE_URL="$DATABASE_URL" TEST_BYPASS_AUTH=1 npx vitest run src/routes/admin/queue/index.test.ts +``` + +Expected: PASS — 3 tests. + +If FAIL with auth issues, double-check the middleware bypass. +If FAIL with seed errors, verify the test DB has the migration applied. + +- [ ] **Step 4: Commit** + +```bash +git add packages/api/src/test/helpers.ts packages/api/src/middleware/requireActorContext.ts +git commit -m "test(api): integration test helpers + TEST_BYPASS_AUTH escape hatch" +``` + +--- + +## Task 21: Integration smoke test — full state machine happy path + +**Files:** +- Create: `packages/api/src/lib/lifecycle/integration.test.ts` + +This is a single end-to-end smoke against the real DB that goes member-submit → 1st staff approve → 2nd staff approve → published. It exercises the Drizzle adapter rather than the in-memory fake. + +- [ ] **Step 1: Write the integration test** + +Write `packages/api/src/lib/lifecycle/integration.test.ts`: + +```ts +import { beforeAll, afterAll, describe, expect, test } from "vitest"; +import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-http"; +import * as schema from "../../db/schema"; +import { applyTransition } from "./applyTransition"; +import { drizzleLifecycleDb } from "./drizzleAdapter"; + +const sql = neon(process.env.DATABASE_URL!); +const db = drizzle(sql, { schema }); + +const TEST_EVENT_ID = "00000000-0000-0000-0000-000000000a01"; +const TEST_AUTHOR_ID = "00000000-0000-0000-0000-000000000a02"; +const TEST_REVIEWER_1 = "00000000-0000-0000-0000-000000000a03"; +const TEST_REVIEWER_2 = "00000000-0000-0000-0000-000000000a04"; + +beforeAll(async () => { + // Cleanup any prior runs + await sql`DELETE FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM users WHERE id IN (${TEST_AUTHOR_ID}::uuid, ${TEST_REVIEWER_1}::uuid, ${TEST_REVIEWER_2}::uuid)`; + + // Seed three users (we need them to satisfy author_id + reviewer_id FKs) + for (const [id, role] of [ + [TEST_AUTHOR_ID, "member"], + [TEST_REVIEWER_1, "staff"], + [TEST_REVIEWER_2, "staff"], + ] as const) { + await sql` + INSERT INTO users (id, email, role, status) + VALUES (${id}::uuid, ${id + "@test"}, ${role}::user_role, 'active') + `; + } + + // Seed a draft event + await sql` + INSERT INTO events (id, slug, name, type, start_date, status, revision, author_id, scope) + VALUES ( + ${TEST_EVENT_ID}::uuid, 'integration-test-event', 'Integration Test Event', + 'workshop', '2099-12-31'::date, 'draft', 1, ${TEST_AUTHOR_ID}::uuid, 'community' + ) + `; +}); + +afterAll(async () => { + await sql`DELETE FROM artifact_reviews WHERE entity_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM users WHERE id IN (${TEST_AUTHOR_ID}::uuid, ${TEST_REVIEWER_1}::uuid, ${TEST_REVIEWER_2}::uuid)`; +}); + +describe("artifact lifecycle integration", () => { + test("member submits, two staff approve, event publishes", async () => { + // 1. Author submits + { + const lifecycleDb = drizzleLifecycleDb(db, { id: TEST_AUTHOR_ID, role: "member" }); + const result = await applyTransition(lifecycleDb, { + entityType: "event", + entityId: TEST_EVENT_ID, + action: "submit_for_review", + actorId: TEST_AUTHOR_ID, + }); + expect(result.ok).toBe(true); + } + + // 2. First reviewer approves + { + const lifecycleDb = drizzleLifecycleDb(db, { id: TEST_REVIEWER_1, role: "staff" }); + const result = await applyTransition(lifecycleDb, { + entityType: "event", + entityId: TEST_EVENT_ID, + action: "approve", + actorId: TEST_REVIEWER_1, + }); + expect(result.ok).toBe(true); + } + + // Status should still be in_review + const afterFirst = await sql`SELECT status FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + expect(afterFirst[0].status).toBe("in_review"); + + // 3. Second reviewer approves — should publish + { + const lifecycleDb = drizzleLifecycleDb(db, { id: TEST_REVIEWER_2, role: "staff" }); + const result = await applyTransition(lifecycleDb, { + entityType: "event", + entityId: TEST_EVENT_ID, + action: "approve", + actorId: TEST_REVIEWER_2, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.newStatus).toBe("published"); + } + + const afterSecond = await sql`SELECT status FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + expect(afterSecond[0].status).toBe("published"); + + // Audit log should contain 4 entries: submit, approve, publish (no separate "approve" emitted for the 2nd because publish supersedes), submit_for_review + const audits = await sql` + SELECT action FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid ORDER BY created_at + `; + const actions = audits.map((a) => a.action); + expect(actions).toContain("events.submit_for_review"); + expect(actions).toContain("events.approve"); + expect(actions).toContain("events.publish"); + }); +}); +``` + +- [ ] **Step 2: Run the integration test** + +```bash +cd packages/api && DATABASE_URL="$DATABASE_URL" npx vitest run src/lib/lifecycle/integration.test.ts +``` + +Expected: PASS — 1 test, includes 3 internal applyTransition calls + a status query + audit assertion. + +If FAIL with FK errors, the users table may have additional NOT NULL columns the seed missed; inspect `\d users` and add the needed columns to the seed. + +- [ ] **Step 3: Commit** + +```bash +git add packages/api/src/lib/lifecycle/integration.test.ts +git commit -m "test(api): integration smoke for lifecycle happy path (submit → 2x approve → publish)" +``` + +--- + +## Task 22: Run full test suite + typecheck + +- [ ] **Step 1: Run all vitest tests** + +```bash +cd packages/api && DATABASE_URL="$DATABASE_URL" TEST_BYPASS_AUTH=1 npm test +cd ../.. +``` + +Expected: all green. Fix any regressions before continuing. + +- [ ] **Step 2: Run full repo typecheck** + +```bash +npm run typecheck +``` + +Expected: `Tasks: 5 successful, 5 total`. + +- [ ] **Step 3: If anything failed, fix and re-run** + +The most likely failures: +- Drizzle adapter type strictness on the dynamic `artifactTable()` return — split into one branch per entity type if needed. +- Test fixtures missing required columns — inspect `\d events`, `\d users` and add columns to the seed inserts. + +--- + +## Task 23: Open the PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin cdcore09/artifact-foundation +``` + +- [ ] **Step 2: Open the PR against `cdcore09/site-redesign`** + +```bash +gh pr create --base cdcore09/site-redesign --title "feat(api): artifact subsystem foundation (schema + lifecycle + queue)" --body "$(cat <<'EOF' +## Summary + +Plan 1 of 5 from the events / announcements / forms brainstorm. Lands the shared substrate that the next four plans (events, announcements, forms, broadcast) all sit on top of. + +- Migration 0022: extends `events` with `status`/`revision`/`author_id`/`scope`/`host_group_id`/`host_org_id`/`external_url`/`thumbnail_key`; adds `announcements`, `forms`, `form_submissions`, `artifact_reviews`, `artifact_comments`, `broadcast_requests`, `broadcast_channels`; six new pg enums. +- Lifecycle library at `packages/api/src/lib/lifecycle/`: valid transitions table, approval counter with self-promotion guard, effectiveStatus read-time auto-transitions, and applyTransition orchestrator (validates, persists review row, counts approvals, publishes on threshold, emits audit). +- Policies: `canEditArtifact` (staff or author-on-draft/changes_requested), `canReviewArtifact` (staff + not-the-author). +- New endpoints: `GET /admin/queue` (UNION ALL across in_review artifacts) and `GET /announcements/active-banner` (returns null until Plan 3). +- Test infrastructure: integration helpers + a TEST_BYPASS_AUTH escape hatch for header-shaped actor stubs. + +No user-facing UI in this PR. Plans 2-5 add events, announcements, forms, and broadcast surfaces on top of this foundation. + +## Test plan + +- [ ] CI is green (typecheck + vitest) +- [ ] `curl https:///api/announcements/active-banner` returns `{"banner":null}` +- [ ] `curl -H 'Authorization: ' https:///api/admin/queue` returns `{"ok":true,"rows":[]}` +- [ ] Integration test verifies the submit → 2x approve → publish path against the staging DB +EOF +)" +``` + +- [ ] **Step 3: Verify CI** + +```bash +gh pr checks +``` + +Expected: Cloudflare Pages preview both succeed; CircleCI may report a non-blocking error on legacy CI (expected on site-redesign-base PRs, per session memory). + +--- + +## Wrap + +Plan 1 (Foundation) is done when: + +1. Migration 0022 applied to staging without errors. +2. All vitest suites pass (lifecycle unit tests + integration smoke + policy tests + queue route tests). +3. Full repo typecheck passes. +4. PR opened against `cdcore09/site-redesign` with the test plan checked off. + +**Next:** Plan 2 (Events) — admin events list/detail/new, member submit, public `/events` extension, `/events/submit`, `/events/:slug` extension. Builds on the schema + lifecycle in this PR. + +--- + +## Summary + +This plan lands the schema migration, lifecycle library, policies, audit verbs, comment sanitizer, queue endpoint, and active-banner stub — everything that the three artifact subsystems (events, announcements, forms) need before their UI work can begin. The lifecycle library is independently tested with an in-memory DB stub and end-to-end against the real DB via an integration smoke test. Plans 2-5 sit on this foundation and add the user-facing surfaces. + +## Test plan + +- [ ] All vitest unit tests pass (transitions, effectiveStatus, approvals, applyTransition, comments, policies) +- [ ] Integration test passes against the test DB (submit → approve x2 → publish, with audit_log entries) +- [ ] Queue endpoint returns expected rows when seeded with mixed-type in_review artifacts +- [ ] `GET /announcements/active-banner` returns `{"banner":null}` against a clean DB +- [ ] Full repo typecheck passes (`npm run typecheck`) +- [ ] PR CI green on the Cloudflare Pages checks From c58b7337e4e84bf990ec142fa411d54316fe2729 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Wed, 20 May 2026 16:27:44 -0500 Subject: [PATCH 03/26] feat(db): add artifact subsystem schema (events extension + announcements + forms + polymorphic tables) --- .../migrations/0022_artifact_subsystem.sql | 204 ++++++++++++++++++ packages/api/migrations/meta/_journal.json | 21 ++ 2 files changed, 225 insertions(+) create mode 100644 packages/api/migrations/0022_artifact_subsystem.sql diff --git a/packages/api/migrations/0022_artifact_subsystem.sql b/packages/api/migrations/0022_artifact_subsystem.sql new file mode 100644 index 000000000..0a4785837 --- /dev/null +++ b/packages/api/migrations/0022_artifact_subsystem.sql @@ -0,0 +1,204 @@ +-- Shared enums --------------------------------------------------------------- + +CREATE TYPE "artifact_status" AS ENUM ( + 'draft', + 'in_review', + 'changes_requested', + 'rejected', + 'published', + 'cancelled', + 'completed', + 'expired', + 'closed', + 'archived' +);--> statement-breakpoint + +CREATE TYPE "artifact_scope" AS ENUM ( + 'public', + 'community', + 'group', + 'staff_only' +);--> statement-breakpoint + +CREATE TYPE "artifact_review_decision" AS ENUM ( + 'approve', + 'reject', + 'request_changes' +);--> statement-breakpoint + +CREATE TYPE "broadcast_channel" AS ENUM ( + 'site_banner', + 'workspace_chat', + 'newsletter', + 'twitter_x', + 'bluesky', + 'mastodon', + 'linkedin' +);--> statement-breakpoint + +CREATE TYPE "broadcast_channel_status" AS ENUM ( + 'requested', + 'approved', + 'declined', + 'posted' +);--> statement-breakpoint + +CREATE TYPE "artifact_entity_type" AS ENUM ( + 'event', + 'announcement', + 'form', + 'group' +);--> statement-breakpoint + +-- Extend existing events table ---------------------------------------------- +-- Existing events are already live on the public site, so they default to +-- status='published' + scope='public'. After backfill we flip the column +-- defaults so new INSERTs default to draft/community. + +ALTER TABLE "events" + ADD COLUMN "status" "artifact_status" NOT NULL DEFAULT 'published', + ADD COLUMN "revision" integer NOT NULL DEFAULT 1, + ADD COLUMN "author_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + ADD COLUMN "scope" "artifact_scope" NOT NULL DEFAULT 'public', + ADD COLUMN "host_group_id" uuid REFERENCES "groups"("id") ON DELETE SET NULL, + ADD COLUMN "host_org_id" uuid REFERENCES "organizations"("id") ON DELETE SET NULL, + ADD COLUMN "external_url" text, + ADD COLUMN "thumbnail_key" text;--> statement-breakpoint + +ALTER TABLE "events" ALTER COLUMN "status" SET DEFAULT 'draft';--> statement-breakpoint +ALTER TABLE "events" ALTER COLUMN "scope" SET DEFAULT 'community';--> statement-breakpoint + +CREATE INDEX "events_status_idx" ON "events" ("status", "created_at" DESC) + WHERE "deleted_at" IS NULL;--> statement-breakpoint + +-- announcements -------------------------------------------------------------- + +CREATE TABLE "announcements" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "status" "artifact_status" NOT NULL DEFAULT 'draft', + "revision" integer NOT NULL DEFAULT 1, + "author_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "scope" "artifact_scope" NOT NULL DEFAULT 'community', + "host_group_id" uuid REFERENCES "groups"("id") ON DELETE SET NULL, + "host_org_id" uuid REFERENCES "organizations"("id") ON DELETE SET NULL, + "title" text NOT NULL, + "body" text NOT NULL, + "link_url" text, + "expires_at" timestamptz, + "thumbnail_key" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz +);--> statement-breakpoint + +CREATE INDEX "announcements_status_idx" ON "announcements" ("status", "created_at" DESC) + WHERE "deleted_at" IS NULL;--> statement-breakpoint + +-- forms ---------------------------------------------------------------------- + +CREATE TABLE "forms" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "status" "artifact_status" NOT NULL DEFAULT 'draft', + "revision" integer NOT NULL DEFAULT 1, + "author_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "scope" "artifact_scope" NOT NULL DEFAULT 'community', + "host_group_id" uuid REFERENCES "groups"("id") ON DELETE SET NULL, + "slug" text NOT NULL UNIQUE, + "title" text NOT NULL, + "description" text, + "schema" jsonb NOT NULL, + "entity_type" "artifact_entity_type", + "entity_id" uuid, + "accepts_submissions" boolean NOT NULL DEFAULT true, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + CONSTRAINT "forms_entity_both_or_neither" + CHECK (("entity_type" IS NULL) = ("entity_id" IS NULL)) +);--> statement-breakpoint + +CREATE INDEX "forms_status_idx" ON "forms" ("status", "created_at" DESC) + WHERE "deleted_at" IS NULL;--> statement-breakpoint + +CREATE INDEX "forms_entity_idx" ON "forms" ("entity_type", "entity_id") + WHERE "entity_type" IS NOT NULL;--> statement-breakpoint + +-- form_submissions ----------------------------------------------------------- + +CREATE TABLE "form_submissions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "form_id" uuid NOT NULL REFERENCES "forms"("id") ON DELETE CASCADE, + "form_revision" integer NOT NULL, + "submitter_user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "payload" jsonb NOT NULL, + "submitted_at" timestamptz NOT NULL DEFAULT now() +);--> statement-breakpoint + +CREATE INDEX "form_submissions_form_idx" ON "form_submissions" ("form_id", "submitted_at" DESC);--> statement-breakpoint + +-- artifact_reviews ----------------------------------------------------------- + +CREATE TABLE "artifact_reviews" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "entity_type" "artifact_entity_type" NOT NULL, + "entity_id" uuid NOT NULL, + "entity_revision" integer NOT NULL, + "reviewer_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE RESTRICT, + "decision" "artifact_review_decision" NOT NULL, + "comment" text, + "created_at" timestamptz NOT NULL DEFAULT now() +);--> statement-breakpoint + +CREATE INDEX "artifact_reviews_entity_idx" + ON "artifact_reviews" ("entity_type", "entity_id", "entity_revision");--> statement-breakpoint + +-- artifact_comments ---------------------------------------------------------- + +CREATE TABLE "artifact_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "entity_type" "artifact_entity_type" NOT NULL, + "entity_id" uuid NOT NULL, + "author_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE RESTRICT, + "body" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now() +);--> statement-breakpoint + +CREATE INDEX "artifact_comments_entity_idx" + ON "artifact_comments" ("entity_type", "entity_id", "created_at");--> statement-breakpoint + +-- broadcast_requests --------------------------------------------------------- + +CREATE TABLE "broadcast_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "entity_type" "artifact_entity_type" NOT NULL, + "entity_id" uuid NOT NULL, + "created_by" uuid NOT NULL REFERENCES "users"("id") ON DELETE RESTRICT, + "created_at" timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "broadcast_requests_unique_per_artifact" + UNIQUE ("entity_type", "entity_id") +);--> statement-breakpoint + +-- broadcast_channels --------------------------------------------------------- + +CREATE TABLE "broadcast_channels" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "broadcast_request_id" uuid NOT NULL + REFERENCES "broadcast_requests"("id") ON DELETE CASCADE, + "channel" "broadcast_channel" NOT NULL, + "status" "broadcast_channel_status" NOT NULL DEFAULT 'requested', + "decided_by" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "decided_at" timestamptz, + "posted_by" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "posted_at" timestamptz, + "post_url" text, + "decline_reason" text, + "prepared_text" text, + "prepared_image_key" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "broadcast_channels_unique_channel_per_request" + UNIQUE ("broadcast_request_id", "channel") +);--> statement-breakpoint + +CREATE INDEX "broadcast_channels_status_idx" + ON "broadcast_channels" ("status", "channel") + WHERE "status" IN ('approved', 'posted'); diff --git a/packages/api/migrations/meta/_journal.json b/packages/api/migrations/meta/_journal.json index 6f59cb9f4..55492a481 100644 --- a/packages/api/migrations/meta/_journal.json +++ b/packages/api/migrations/meta/_journal.json @@ -141,6 +141,27 @@ "when": 1778788921927, "tag": "0019_groups_publishable", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1779043200000, + "tag": "0020_organizations_public_profile", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1779129600000, + "tag": "0021_profile_slack_username", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1779216000000, + "tag": "0022_artifact_subsystem", + "breakpoints": true } ] } \ No newline at end of file From 6a928a5b7ab1c17f35e926ebfd4faba5975b319c Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Wed, 20 May 2026 18:37:09 -0500 Subject: [PATCH 04/26] feat(db): add Drizzle enums for artifact subsystem --- packages/api/src/db/schema/enums.ts | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/api/src/db/schema/enums.ts b/packages/api/src/db/schema/enums.ts index 5adf8a39f..db7002c97 100644 --- a/packages/api/src/db/schema/enums.ts +++ b/packages/api/src/db/schema/enums.ts @@ -148,3 +148,53 @@ export const contributionKind = pgEnum("contribution_kind", [ "podcast", "other", ]); + +export const artifactStatus = pgEnum("artifact_status", [ + "draft", + "in_review", + "changes_requested", + "rejected", + "published", + "cancelled", + "completed", + "expired", + "closed", + "archived", +]); + +export const artifactScope = pgEnum("artifact_scope", [ + "public", + "community", + "group", + "staff_only", +]); + +export const artifactReviewDecision = pgEnum("artifact_review_decision", [ + "approve", + "reject", + "request_changes", +]); + +export const broadcastChannelEnum = pgEnum("broadcast_channel", [ + "site_banner", + "workspace_chat", + "newsletter", + "twitter_x", + "bluesky", + "mastodon", + "linkedin", +]); + +export const broadcastChannelStatus = pgEnum("broadcast_channel_status", [ + "requested", + "approved", + "declined", + "posted", +]); + +export const artifactEntityType = pgEnum("artifact_entity_type", [ + "event", + "announcement", + "form", + "group", +]); From 2a56ab1a0028f98bcf3689442d9d70c9e582d8e4 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Wed, 20 May 2026 23:30:11 -0500 Subject: [PATCH 05/26] refactor(db): drop redundant Enum suffix on broadcastChannel pgEnum --- packages/api/src/db/schema/enums.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/db/schema/enums.ts b/packages/api/src/db/schema/enums.ts index db7002c97..96d2c55c5 100644 --- a/packages/api/src/db/schema/enums.ts +++ b/packages/api/src/db/schema/enums.ts @@ -175,7 +175,7 @@ export const artifactReviewDecision = pgEnum("artifact_review_decision", [ "request_changes", ]); -export const broadcastChannelEnum = pgEnum("broadcast_channel", [ +export const broadcastChannel = pgEnum("broadcast_channel", [ "site_banner", "workspace_chat", "newsletter", From a14b6340916fe9ca4664d78fffa2e783d9e2afed Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Thu, 21 May 2026 17:54:53 -0500 Subject: [PATCH 06/26] feat(db): extend events schema with artifact columns --- packages/api/src/db/schema/events.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/api/src/db/schema/events.ts b/packages/api/src/db/schema/events.ts index 83ae467fc..228ea7297 100644 --- a/packages/api/src/db/schema/events.ts +++ b/packages/api/src/db/schema/events.ts @@ -2,13 +2,21 @@ import { relations } from "drizzle-orm"; import { date, index, + integer, pgTable, text, timestamp, uniqueIndex, uuid, } from "drizzle-orm/pg-core"; -import { eventAttendanceRole, eventType } from "./enums"; +import { + artifactScope, + artifactStatus, + eventAttendanceRole, + eventType, +} from "./enums"; +import { groups } from "./groups"; +import { organizations } from "./vocab"; import { users } from "./users"; export const events = pgTable( @@ -23,6 +31,21 @@ export const events = pgTable( location: text("location"), url: text("url"), description: text("description"), + // Artifact-subsystem columns (added in migration 0022) + status: artifactStatus("status").notNull().default("draft"), + revision: integer("revision").notNull().default(1), + authorId: uuid("author_id").references(() => users.id, { + onDelete: "set null", + }), + scope: artifactScope("scope").notNull().default("community"), + hostGroupId: uuid("host_group_id").references(() => groups.id, { + onDelete: "set null", + }), + hostOrgId: uuid("host_org_id").references(() => organizations.id, { + onDelete: "set null", + }), + externalUrl: text("external_url"), + thumbnailKey: text("thumbnail_key"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -34,6 +57,7 @@ export const events = pgTable( (t) => [ index("events_start_date_idx").on(t.startDate), index("events_type_idx").on(t.type), + index("events_status_idx").on(t.status, t.createdAt), ] ); From a2e0794fbfe196e1c58b1fb3c33cf6238f7777fa Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Thu, 21 May 2026 18:06:04 -0500 Subject: [PATCH 07/26] fix(db): add deleted_at partial-index predicate on events_status_idx --- packages/api/src/db/schema/events.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/db/schema/events.ts b/packages/api/src/db/schema/events.ts index 228ea7297..81910795e 100644 --- a/packages/api/src/db/schema/events.ts +++ b/packages/api/src/db/schema/events.ts @@ -1,4 +1,4 @@ -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { date, index, @@ -57,7 +57,7 @@ export const events = pgTable( (t) => [ index("events_start_date_idx").on(t.startDate), index("events_type_idx").on(t.type), - index("events_status_idx").on(t.status, t.createdAt), + index("events_status_idx").on(t.status, t.createdAt).where(sql`deleted_at IS NULL`), ] ); From 8271d66fdc249c006578e201b5b3adfb815fb9c9 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Thu, 21 May 2026 18:08:34 -0500 Subject: [PATCH 08/26] feat(db): add announcements Drizzle schema --- packages/api/src/db/schema/announcements.ts | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/api/src/db/schema/announcements.ts diff --git a/packages/api/src/db/schema/announcements.ts b/packages/api/src/db/schema/announcements.ts new file mode 100644 index 000000000..756759a96 --- /dev/null +++ b/packages/api/src/db/schema/announcements.ts @@ -0,0 +1,64 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + integer, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { artifactScope, artifactStatus } from "./enums"; +import { groups } from "./groups"; +import { organizations } from "./vocab"; +import { users } from "./users"; + +export const announcements = pgTable( + "announcements", + { + id: uuid("id").primaryKey().defaultRandom(), + status: artifactStatus("status").notNull().default("draft"), + revision: integer("revision").notNull().default(1), + authorId: uuid("author_id").references(() => users.id, { + onDelete: "set null", + }), + scope: artifactScope("scope").notNull().default("community"), + hostGroupId: uuid("host_group_id").references(() => groups.id, { + onDelete: "set null", + }), + hostOrgId: uuid("host_org_id").references(() => organizations.id, { + onDelete: "set null", + }), + title: text("title").notNull(), + body: text("body").notNull(), + linkUrl: text("link_url"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + thumbnailKey: text("thumbnail_key"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [ + index("announcements_status_idx") + .on(t.status, t.createdAt) + .where(sql`deleted_at IS NULL`), + ] +); + +export const announcementsRelations = relations(announcements, ({ one }) => ({ + author: one(users, { + fields: [announcements.authorId], + references: [users.id], + }), + hostGroup: one(groups, { + fields: [announcements.hostGroupId], + references: [groups.id], + }), + hostOrg: one(organizations, { + fields: [announcements.hostOrgId], + references: [organizations.id], + }), +})); From 2e8a97c906c350f8887e6a1369d0232cb07d7a64 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Thu, 21 May 2026 18:14:46 -0500 Subject: [PATCH 09/26] feat(db): add forms + form_submissions Drizzle schema --- packages/api/src/db/schema/forms.ts | 95 +++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/api/src/db/schema/forms.ts diff --git a/packages/api/src/db/schema/forms.ts b/packages/api/src/db/schema/forms.ts new file mode 100644 index 000000000..caecd0c89 --- /dev/null +++ b/packages/api/src/db/schema/forms.ts @@ -0,0 +1,95 @@ +import { relations, sql } from "drizzle-orm"; +import { + boolean, + check, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { + artifactEntityType, + artifactScope, + artifactStatus, +} from "./enums"; +import { groups } from "./groups"; +import { users } from "./users"; + +export const forms = pgTable( + "forms", + { + id: uuid("id").primaryKey().defaultRandom(), + status: artifactStatus("status").notNull().default("draft"), + revision: integer("revision").notNull().default(1), + authorId: uuid("author_id").references(() => users.id, { + onDelete: "set null", + }), + scope: artifactScope("scope").notNull().default("community"), + hostGroupId: uuid("host_group_id").references(() => groups.id, { + onDelete: "set null", + }), + slug: text("slug").notNull().unique(), + title: text("title").notNull(), + description: text("description"), + schema: jsonb("schema").notNull(), + entityType: artifactEntityType("entity_type"), + entityId: uuid("entity_id"), + acceptsSubmissions: boolean("accepts_submissions").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => [ + index("forms_status_idx") + .on(t.status, t.createdAt) + .where(sql`deleted_at IS NULL`), + index("forms_entity_idx") + .on(t.entityType, t.entityId) + .where(sql`entity_type IS NOT NULL`), + check( + "forms_entity_both_or_neither", + sql`(${t.entityType} IS NULL) = (${t.entityId} IS NULL)` + ), + ] +); + +export const formSubmissions = pgTable( + "form_submissions", + { + id: uuid("id").primaryKey().defaultRandom(), + formId: uuid("form_id") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + formRevision: integer("form_revision").notNull(), + submitterUserId: uuid("submitter_user_id").references(() => users.id, { + onDelete: "set null", + }), + payload: jsonb("payload").notNull(), + submittedAt: timestamp("submitted_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [index("form_submissions_form_idx").on(t.formId, t.submittedAt)] +); + +export const formsRelations = relations(forms, ({ many, one }) => ({ + submissions: many(formSubmissions), + author: one(users, { + fields: [forms.authorId], + references: [users.id], + }), +})); + +export const formSubmissionsRelations = relations(formSubmissions, ({ one }) => ({ + form: one(forms, { + fields: [formSubmissions.formId], + references: [forms.id], + }), +})); From 17d87e8b2ad3f85d0c58bdf74d3cba13db8e354a Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Thu, 21 May 2026 18:21:35 -0500 Subject: [PATCH 10/26] feat(db): add polymorphic artifact tables (reviews, comments, broadcasts) --- packages/api/src/db/schema/artifacts.ts | 119 ++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 packages/api/src/db/schema/artifacts.ts diff --git a/packages/api/src/db/schema/artifacts.ts b/packages/api/src/db/schema/artifacts.ts new file mode 100644 index 000000000..fcaf68a33 --- /dev/null +++ b/packages/api/src/db/schema/artifacts.ts @@ -0,0 +1,119 @@ +import { sql } from "drizzle-orm"; +import { + index, + integer, + pgTable, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; +import { + artifactEntityType, + artifactReviewDecision, + broadcastChannel, + broadcastChannelStatus, +} from "./enums"; +import { users } from "./users"; + +export const artifactReviews = pgTable( + "artifact_reviews", + { + id: uuid("id").primaryKey().defaultRandom(), + entityType: artifactEntityType("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + entityRevision: integer("entity_revision").notNull(), + reviewerId: uuid("reviewer_id") + .notNull() + .references(() => users.id, { onDelete: "restrict" }), + decision: artifactReviewDecision("decision").notNull(), + comment: text("comment"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("artifact_reviews_entity_idx").on( + t.entityType, + t.entityId, + t.entityRevision + ), + ] +); + +export const artifactComments = pgTable( + "artifact_comments", + { + id: uuid("id").primaryKey().defaultRandom(), + entityType: artifactEntityType("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + authorId: uuid("author_id") + .notNull() + .references(() => users.id, { onDelete: "restrict" }), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("artifact_comments_entity_idx").on( + t.entityType, + t.entityId, + t.createdAt + ), + ] +); + +export const broadcastRequests = pgTable( + "broadcast_requests", + { + id: uuid("id").primaryKey().defaultRandom(), + entityType: artifactEntityType("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + createdBy: uuid("created_by") + .notNull() + .references(() => users.id, { onDelete: "restrict" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + unique("broadcast_requests_unique_per_artifact").on(t.entityType, t.entityId), + ] +); + +export const broadcastChannels = pgTable( + "broadcast_channels", + { + id: uuid("id").primaryKey().defaultRandom(), + broadcastRequestId: uuid("broadcast_request_id") + .notNull() + .references(() => broadcastRequests.id, { onDelete: "cascade" }), + channel: broadcastChannel("channel").notNull(), + status: broadcastChannelStatus("status").notNull().default("requested"), + decidedBy: uuid("decided_by").references(() => users.id, { + onDelete: "set null", + }), + decidedAt: timestamp("decided_at", { withTimezone: true }), + postedBy: uuid("posted_by").references(() => users.id, { + onDelete: "set null", + }), + postedAt: timestamp("posted_at", { withTimezone: true }), + postUrl: text("post_url"), + declineReason: text("decline_reason"), + preparedText: text("prepared_text"), + preparedImageKey: text("prepared_image_key"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + unique("broadcast_channels_unique_channel_per_request").on( + t.broadcastRequestId, + t.channel + ), + index("broadcast_channels_status_idx") + .on(t.status, t.channel) + .where(sql`status IN ('approved', 'posted')`), + ] +); From 1bbe447119f2a92a18ac9969248f137afc3acd7f Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Thu, 21 May 2026 18:27:10 -0500 Subject: [PATCH 11/26] feat(db): register new schema modules in barrel --- packages/api/src/db/schema/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/api/src/db/schema/index.ts b/packages/api/src/db/schema/index.ts index e0a0b8b59..f9c7aa7f9 100644 --- a/packages/api/src/db/schema/index.ts +++ b/packages/api/src/db/schema/index.ts @@ -11,3 +11,6 @@ export * from "./leadership"; export * from "./works"; export * from "./audit"; export * from "./recognition"; +export * from "./announcements"; +export * from "./forms"; +export * from "./artifacts"; From 25dac30b2fe44250d9e55148afec2565ec38e673 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Thu, 21 May 2026 18:55:47 -0500 Subject: [PATCH 12/26] feat(api): add lifecycle library types --- packages/api/src/lib/lifecycle/types.ts | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 packages/api/src/lib/lifecycle/types.ts diff --git a/packages/api/src/lib/lifecycle/types.ts b/packages/api/src/lib/lifecycle/types.ts new file mode 100644 index 000000000..0e06143ba --- /dev/null +++ b/packages/api/src/lib/lifecycle/types.ts @@ -0,0 +1,57 @@ +/** + * Shared types for the artifact lifecycle library. + * + * Three artifact types share a status machine; each only uses the subset + * of states that makes sense. See spec §2. + */ + +export type ArtifactStatus = + | "draft" + | "in_review" + | "changes_requested" + | "rejected" + | "published" + | "cancelled" + | "completed" + | "expired" + | "closed" + | "archived"; + +export type ArtifactScope = "public" | "community" | "group" | "staff_only"; + +export type ArtifactEntityType = "event" | "announcement" | "form" | "group"; + +export type ReviewDecision = "approve" | "reject" | "request_changes"; + +/** + * Transitions an actor can request via the API. Internal-only transitions + * (the system-driven ones like auto-`completed` / auto-`expired`) are + * intentionally not in this set; they're computed by `effectiveStatus`. + */ +export type LifecycleAction = + | "submit_for_review" + | "request_changes" + | "reject" + | "approve" + | "cancel" + | "archive" + | "close" + | "publish"; // synthetic — emitted by applyTransition when the 2nd approval lands + +/** + * Inputs an applyTransition call needs at the row level. The lifecycle + * library doesn't know which concrete table the artifact lives in; the + * caller passes the resolved row + the entity_type. + */ +export interface ArtifactSnapshot { + id: string; + entityType: ArtifactEntityType; + status: ArtifactStatus; + revision: number; + authorId: string | null; + /** end_date for events, expires_at for announcements, undefined for forms */ + effectiveStatusInputs?: { + endDate?: string | null; + expiresAt?: Date | null; + }; +} From eaa4bf6ca6f8bb20b0eecb4cb62685be468adf1d Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Fri, 22 May 2026 20:49:29 -0500 Subject: [PATCH 13/26] =?UTF-8?q?feat(api):=20lifecycle=20library=20?= =?UTF-8?q?=E2=80=94=20valid=20transitions=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/lib/lifecycle/transitions.test.ts | 47 ++++++++++++++ packages/api/src/lib/lifecycle/transitions.ts | 64 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 packages/api/src/lib/lifecycle/transitions.test.ts create mode 100644 packages/api/src/lib/lifecycle/transitions.ts diff --git a/packages/api/src/lib/lifecycle/transitions.test.ts b/packages/api/src/lib/lifecycle/transitions.test.ts new file mode 100644 index 000000000..963297aeb --- /dev/null +++ b/packages/api/src/lib/lifecycle/transitions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import { isValidTransition } from "./transitions"; + +describe("isValidTransition", () => { + test("draft → submit_for_review is valid for all artifact types", () => { + for (const t of ["event", "announcement", "form"] as const) { + expect(isValidTransition(t, "draft", "submit_for_review")).toBe(true); + } + }); + + test("draft → approve is invalid (must submit_for_review first)", () => { + expect(isValidTransition("event", "draft", "approve")).toBe(false); + }); + + test("in_review → approve / reject / request_changes are valid", () => { + expect(isValidTransition("event", "in_review", "approve")).toBe(true); + expect(isValidTransition("event", "in_review", "reject")).toBe(true); + expect(isValidTransition("event", "in_review", "request_changes")).toBe(true); + }); + + test("changes_requested → submit_for_review (resubmit) is valid", () => { + expect(isValidTransition("event", "changes_requested", "submit_for_review")).toBe(true); + }); + + test("rejected is terminal", () => { + expect(isValidTransition("event", "rejected", "submit_for_review")).toBe(false); + expect(isValidTransition("event", "rejected", "approve")).toBe(false); + }); + + test("published → cancel valid for events only", () => { + expect(isValidTransition("event", "published", "cancel")).toBe(true); + expect(isValidTransition("announcement", "published", "cancel")).toBe(false); + expect(isValidTransition("form", "published", "cancel")).toBe(false); + }); + + test("published → close valid for forms only", () => { + expect(isValidTransition("form", "published", "close")).toBe(true); + expect(isValidTransition("event", "published", "close")).toBe(false); + expect(isValidTransition("announcement", "published", "close")).toBe(false); + }); + + test("published → archive valid for all types", () => { + expect(isValidTransition("event", "published", "archive")).toBe(true); + expect(isValidTransition("announcement", "published", "archive")).toBe(true); + expect(isValidTransition("form", "published", "archive")).toBe(true); + }); +}); diff --git a/packages/api/src/lib/lifecycle/transitions.ts b/packages/api/src/lib/lifecycle/transitions.ts new file mode 100644 index 000000000..30bcd39af --- /dev/null +++ b/packages/api/src/lib/lifecycle/transitions.ts @@ -0,0 +1,64 @@ +import type { ArtifactEntityType, ArtifactStatus, LifecycleAction } from "./types"; + +/** + * Valid transitions: (entity type, current status) → set of allowed actions. + * + * Reject and request_changes are single-reviewer; approve is the only + * action that requires the 2-vote tally to actually move state to + * `published`. Cancel/archive/close are post-publish administrative + * actions and only valid from `published`. + */ +type TransitionMap = Partial>>; + +const SHARED_TRANSITIONS: TransitionMap = { + draft: ["submit_for_review"], + in_review: ["approve", "reject", "request_changes"], + changes_requested: ["submit_for_review"], + // rejected: terminal + published: ["archive"], + // archived: terminal +}; + +const TYPE_OVERRIDES: Record = { + event: { + published: ["cancel", "archive"], + // completed: auto, terminal + // cancelled: terminal + }, + announcement: { + // expired: auto, terminal + }, + form: { + published: ["close", "archive"], + closed: ["archive"], + }, + group: { + // groups aren't a real artifact type for lifecycle purposes — included + // in the entity-type enum because comments/reviews can attach to them + // in future. No transitions defined here. + }, +}; + +export function isValidTransition( + entityType: ArtifactEntityType, + currentStatus: ArtifactStatus, + action: LifecycleAction +): boolean { + const typeOverride = TYPE_OVERRIDES[entityType][currentStatus]; + if (typeOverride !== undefined) { + return typeOverride.includes(action); + } + const shared = SHARED_TRANSITIONS[currentStatus]; + return shared !== undefined && shared.includes(action); +} + +export function allowedActionsFor( + entityType: ArtifactEntityType, + currentStatus: ArtifactStatus +): ReadonlyArray { + return ( + TYPE_OVERRIDES[entityType][currentStatus] ?? + SHARED_TRANSITIONS[currentStatus] ?? + [] + ); +} From 0655fc96a1fbf29858baa3371e082ab82237e066 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Fri, 22 May 2026 20:56:25 -0500 Subject: [PATCH 14/26] =?UTF-8?q?feat(api):=20lifecycle=20library=20?= =?UTF-8?q?=E2=80=94=20effectiveStatus=20read-time=20auto-transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/lifecycle/effectiveStatus.test.ts | 92 +++++++++++++++++++ .../api/src/lib/lifecycle/effectiveStatus.ts | 25 +++++ 2 files changed, 117 insertions(+) create mode 100644 packages/api/src/lib/lifecycle/effectiveStatus.test.ts create mode 100644 packages/api/src/lib/lifecycle/effectiveStatus.ts diff --git a/packages/api/src/lib/lifecycle/effectiveStatus.test.ts b/packages/api/src/lib/lifecycle/effectiveStatus.test.ts new file mode 100644 index 000000000..0e5f26f50 --- /dev/null +++ b/packages/api/src/lib/lifecycle/effectiveStatus.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "vitest"; +import { effectiveStatus } from "./effectiveStatus"; + +describe("effectiveStatus", () => { + test("returns stored status for draft / in_review (no auto-transition)", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "event", + status: "draft", + revision: 1, + authorId: null, + effectiveStatusInputs: { endDate: "2020-01-01" }, + }) + ).toBe("draft"); + }); + + test("published event past end_date auto-transitions to completed", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "event", + status: "published", + revision: 1, + authorId: null, + effectiveStatusInputs: { endDate: "2020-01-01" }, + }) + ).toBe("completed"); + }); + + test("published event with future end_date stays published", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "event", + status: "published", + revision: 1, + authorId: null, + effectiveStatusInputs: { endDate: "2099-12-31" }, + }) + ).toBe("published"); + }); + + test("published announcement past expires_at auto-transitions to expired", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "announcement", + status: "published", + revision: 1, + authorId: null, + effectiveStatusInputs: { expiresAt: new Date("2020-01-01") }, + }) + ).toBe("expired"); + }); + + test("published announcement with no expires_at stays published", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "announcement", + status: "published", + revision: 1, + authorId: null, + }) + ).toBe("published"); + }); + + test("forms never auto-transition", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "form", + status: "published", + revision: 1, + authorId: null, + }) + ).toBe("published"); + }); + + test("terminal states pass through unchanged", () => { + expect( + effectiveStatus({ + id: "x", + entityType: "event", + status: "cancelled", + revision: 1, + authorId: null, + }) + ).toBe("cancelled"); + }); +}); diff --git a/packages/api/src/lib/lifecycle/effectiveStatus.ts b/packages/api/src/lib/lifecycle/effectiveStatus.ts new file mode 100644 index 000000000..23c576752 --- /dev/null +++ b/packages/api/src/lib/lifecycle/effectiveStatus.ts @@ -0,0 +1,25 @@ +import type { ArtifactSnapshot, ArtifactStatus } from "./types"; + +/** + * Compute the effective status for an artifact at read time. + * + * Only `published` rows are subject to auto-transition: + * - events past `end_date` → `completed` + * - announcements past `expires_at` → `expired` + * + * Forms never auto-transition. The stored `status` is returned in every + * other case. + */ +export function effectiveStatus(snap: ArtifactSnapshot): ArtifactStatus { + if (snap.status !== "published") return snap.status; + const now = new Date(); + if (snap.entityType === "event") { + const endDate = snap.effectiveStatusInputs?.endDate; + if (endDate && new Date(endDate) < now) return "completed"; + } + if (snap.entityType === "announcement") { + const expiresAt = snap.effectiveStatusInputs?.expiresAt; + if (expiresAt && expiresAt < now) return "expired"; + } + return "published"; +} From f1be5b215829a1d8b8b3db03a14732501fddfa13 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Fri, 22 May 2026 21:21:32 -0500 Subject: [PATCH 15/26] =?UTF-8?q?feat(api):=20lifecycle=20library=20?= =?UTF-8?q?=E2=80=94=20approval=20counter=20with=20self-promotion=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/lib/lifecycle/approvals.test.ts | 104 ++++++++++++++++++ packages/api/src/lib/lifecycle/approvals.ts | 48 ++++++++ 2 files changed, 152 insertions(+) create mode 100644 packages/api/src/lib/lifecycle/approvals.test.ts create mode 100644 packages/api/src/lib/lifecycle/approvals.ts diff --git a/packages/api/src/lib/lifecycle/approvals.test.ts b/packages/api/src/lib/lifecycle/approvals.test.ts new file mode 100644 index 000000000..831d224b3 --- /dev/null +++ b/packages/api/src/lib/lifecycle/approvals.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import { countValidApprovals } from "./approvals"; + +type Review = { + entityType: "event" | "announcement" | "form" | "group"; + entityId: string; + entityRevision: number; + reviewerId: string; + decision: "approve" | "reject" | "request_changes"; +}; + +/** + * Test helper: a fake DB that filters in-memory rows. Lets us test + * approval logic without spinning up Postgres. + */ +function fakeDb(reviews: Review[]) { + return { + async listApprovalsForRevision( + entityType: Review["entityType"], + entityId: string, + revision: number + ): Promise<{ reviewerId: string }[]> { + return reviews + .filter( + (r) => + r.entityType === entityType && + r.entityId === entityId && + r.entityRevision === revision && + r.decision === "approve" + ) + .map((r) => ({ reviewerId: r.reviewerId })); + }, + }; +} + +describe("countValidApprovals", () => { + test("returns 0 when no approvals exist", async () => { + const db = fakeDb([]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 1, + authorId: "author-1", + }); + expect(n).toBe(0); + }); + + test("counts distinct reviewers, excluding author", async () => { + const db = fakeDb([ + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "approve" }, + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r2", decision: "approve" }, + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "author-1", decision: "approve" }, + ]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 1, + authorId: "author-1", + }); + expect(n).toBe(2); + }); + + test("a single reviewer approving twice still counts as 1", async () => { + const db = fakeDb([ + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "approve" }, + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "approve" }, + ]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 1, + authorId: "author-1", + }); + expect(n).toBe(1); + }); + + test("approvals on a different revision do not count", async () => { + const db = fakeDb([ + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "approve" }, + { entityType: "event", entityId: "e1", entityRevision: 2, reviewerId: "r2", decision: "approve" }, + ]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 2, + authorId: "author-1", + }); + expect(n).toBe(1); + }); + + test("reject and request_changes decisions are not approvals", async () => { + const db = fakeDb([ + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r1", decision: "reject" }, + { entityType: "event", entityId: "e1", entityRevision: 1, reviewerId: "r2", decision: "request_changes" }, + ]); + const n = await countValidApprovals(db, { + entityType: "event", + entityId: "e1", + revision: 1, + authorId: "author-1", + }); + expect(n).toBe(0); + }); +}); diff --git a/packages/api/src/lib/lifecycle/approvals.ts b/packages/api/src/lib/lifecycle/approvals.ts new file mode 100644 index 000000000..9fe0a7802 --- /dev/null +++ b/packages/api/src/lib/lifecycle/approvals.ts @@ -0,0 +1,48 @@ +import type { ArtifactEntityType } from "./types"; + +/** + * The minimal DB surface this module needs. Production callers pass a + * Drizzle-backed implementation; tests pass an in-memory fake. + */ +export interface ApprovalDb { + listApprovalsForRevision( + entityType: ArtifactEntityType, + entityId: string, + revision: number + ): Promise<{ reviewerId: string }[]>; +} + +export interface CountValidApprovalsInput { + entityType: ArtifactEntityType; + entityId: string; + revision: number; + /** Author id is excluded from the approval tally (self-promotion guard). */ + authorId: string | null; +} + +/** + * Count distinct reviewers (excluding the author) who have approved the + * current revision of the artifact. Publish requires count >= 2. + * + * Resubmits bump the artifact's revision, so this function is naturally + * scoped to the active revision via the revision parameter — older + * approvals are silently ignored. + */ +export async function countValidApprovals( + db: ApprovalDb, + input: CountValidApprovalsInput +): Promise { + const approvals = await db.listApprovalsForRevision( + input.entityType, + input.entityId, + input.revision + ); + const distinct = new Set(); + for (const row of approvals) { + if (row.reviewerId === input.authorId) continue; + distinct.add(row.reviewerId); + } + return distinct.size; +} + +export const PUBLISH_APPROVAL_THRESHOLD = 2; From 74ea844254daf154bdcaaf31162ac06e516139a3 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Fri, 22 May 2026 21:29:40 -0500 Subject: [PATCH 16/26] =?UTF-8?q?feat(api):=20lifecycle=20library=20?= =?UTF-8?q?=E2=80=94=20applyTransition=20orchestrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/lifecycle/applyTransition.test.ts | 198 +++++++++++++++ .../api/src/lib/lifecycle/applyTransition.ts | 234 ++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 packages/api/src/lib/lifecycle/applyTransition.test.ts create mode 100644 packages/api/src/lib/lifecycle/applyTransition.ts diff --git a/packages/api/src/lib/lifecycle/applyTransition.test.ts b/packages/api/src/lib/lifecycle/applyTransition.test.ts new file mode 100644 index 000000000..2585ba21f --- /dev/null +++ b/packages/api/src/lib/lifecycle/applyTransition.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from "vitest"; +import { applyTransition, type LifecycleDb } from "./applyTransition"; + +type ArtifactRow = { + id: string; + status: import("./types").ArtifactStatus; + revision: number; + authorId: string | null; +}; + +type AuditRow = { action: string; targetType: string; targetId: string; payload: unknown }; + +function makeDb(initial: ArtifactRow): LifecycleDb & { + events: Record; + reviews: Array<{ entityRevision: number; reviewerId: string; decision: "approve" | "reject" | "request_changes" }>; + audits: AuditRow[]; +} { + const events: Record = { [initial.id]: { ...initial } }; + const reviews: Array<{ entityRevision: number; reviewerId: string; decision: "approve" | "reject" | "request_changes" }> = []; + const audits: AuditRow[] = []; + return { + events, + reviews, + audits, + async fetchArtifact(entityType, id) { + const row = events[id]; + if (!row) return null; + return { + id: row.id, + entityType, + status: row.status, + revision: row.revision, + authorId: row.authorId, + }; + }, + async insertReview({ entityRevision, reviewerId, decision }) { + reviews.push({ entityRevision, reviewerId, decision }); + }, + async listApprovalsForRevision(_entityType, _entityId, revision) { + return reviews + .filter((r) => r.entityRevision === revision && r.decision === "approve") + .map((r) => ({ reviewerId: r.reviewerId })); + }, + async updateArtifactStatus({ entityId, status, bumpRevision }) { + const row = events[entityId]; + if (!row) throw new Error("artifact missing"); + row.status = status; + if (bumpRevision) row.revision += 1; + }, + async insertAudit({ action, targetType, targetId, payload }) { + audits.push({ action, targetType, targetId, payload }); + }, + }; +} + +describe("applyTransition", () => { + test("draft → submit_for_review moves to in_review and emits audit", async () => { + const db = makeDb({ id: "e1", status: "draft", revision: 1, authorId: "author-1" }); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "submit_for_review", + actorId: "author-1", + }); + expect(result.ok).toBe(true); + expect(db.events["e1"].status).toBe("in_review"); + expect(db.audits[0].action).toBe("events.submit_for_review"); + }); + + test("rejecting a draft is invalid (must be in_review)", async () => { + const db = makeDb({ id: "e1", status: "draft", revision: 1, authorId: "author-1" }); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "reject", + actorId: "reviewer-1", + comment: "no", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invalid_transition"); + }); + + test("first approval keeps status at in_review (1 of 2)", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + expect(result.ok).toBe(true); + expect(db.events["e1"].status).toBe("in_review"); + expect(db.reviews).toHaveLength(1); + }); + + test("second distinct approval publishes the artifact and audits 'publish'", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + const second = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-2", + }); + expect(second.ok).toBe(true); + expect(db.events["e1"].status).toBe("published"); + expect(db.audits.map((a) => a.action)).toContain("events.publish"); + }); + + test("author cannot approve their own artifact", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "author-1", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("self_approval_forbidden"); + }); + + test("same reviewer approving twice does not publish", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + expect(db.events["e1"].status).toBe("in_review"); + }); + + test("request_changes moves to changes_requested and requires comment", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + const noComment = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "request_changes", + actorId: "reviewer-1", + }); + expect(noComment.ok).toBe(false); + if (!noComment.ok) expect(noComment.error).toBe("comment_required"); + + const withComment = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "request_changes", + actorId: "reviewer-1", + comment: "Please tighten the title", + }); + expect(withComment.ok).toBe(true); + expect(db.events["e1"].status).toBe("changes_requested"); + }); + + test("resubmit from changes_requested bumps revision and invalidates prior approvals", async () => { + const db = makeDb({ id: "e1", status: "in_review", revision: 1, authorId: "author-1" }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "request_changes", + actorId: "reviewer-2", + comment: "edit", + }); + await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "submit_for_review", + actorId: "author-1", + }); + expect(db.events["e1"].revision).toBe(2); + expect(db.events["e1"].status).toBe("in_review"); + const result = await applyTransition(db, { + entityType: "event", + entityId: "e1", + action: "approve", + actorId: "reviewer-1", + }); + expect(result.ok).toBe(true); + expect(db.events["e1"].status).toBe("in_review"); + }); +}); diff --git a/packages/api/src/lib/lifecycle/applyTransition.ts b/packages/api/src/lib/lifecycle/applyTransition.ts new file mode 100644 index 000000000..8ebee2b43 --- /dev/null +++ b/packages/api/src/lib/lifecycle/applyTransition.ts @@ -0,0 +1,234 @@ +import { + PUBLISH_APPROVAL_THRESHOLD, + countValidApprovals, + type ApprovalDb, +} from "./approvals"; +import { isValidTransition } from "./transitions"; +import type { + ArtifactEntityType, + ArtifactSnapshot, + ArtifactStatus, + LifecycleAction, + ReviewDecision, +} from "./types"; + +export interface LifecycleDb extends ApprovalDb { + fetchArtifact( + entityType: ArtifactEntityType, + id: string + ): Promise; + insertReview(args: { + entityType: ArtifactEntityType; + entityId: string; + entityRevision: number; + reviewerId: string; + decision: ReviewDecision; + comment: string | null; + }): Promise; + updateArtifactStatus(args: { + entityType: ArtifactEntityType; + entityId: string; + status: ArtifactStatus; + bumpRevision: boolean; + }): Promise; + insertAudit(args: { + action: string; + targetType: string; + targetId: string; + payload: Record | null; + }): Promise; +} + +export interface ApplyTransitionInput { + entityType: ArtifactEntityType; + entityId: string; + action: LifecycleAction; + actorId: string; + comment?: string; +} + +export type ApplyTransitionResult = + | { ok: true; newStatus: ArtifactStatus } + | { + ok: false; + error: + | "not_found" + | "invalid_transition" + | "comment_required" + | "self_approval_forbidden" + | "self_review_forbidden"; + }; + +/** + * Atomic transition: validates, persists, and audits in one call. + * + * Map of action → review-decision-side-effect: + * submit_for_review → no review row; sets in_review (bumps revision if from changes_requested) + * approve → review row (approve); status flips to published when threshold met + * reject → review row (reject); status flips to rejected immediately + * request_changes → review row (request_changes); status flips to changes_requested + * cancel → no review row; status flips to cancelled + * archive → no review row; status flips to archived + * close → no review row; status flips to closed + */ +export async function applyTransition( + db: LifecycleDb, + input: ApplyTransitionInput +): Promise { + const artifact = await db.fetchArtifact(input.entityType, input.entityId); + if (!artifact) return { ok: false, error: "not_found" }; + + if (!isValidTransition(input.entityType, artifact.status, input.action)) { + return { ok: false, error: "invalid_transition" }; + } + + if ( + (input.action === "approve" || + input.action === "reject" || + input.action === "request_changes") && + artifact.authorId === input.actorId + ) { + return { + ok: false, + error: + input.action === "approve" + ? "self_approval_forbidden" + : "self_review_forbidden", + }; + } + + if ( + (input.action === "request_changes" || input.action === "reject") && + !input.comment?.trim() + ) { + return { ok: false, error: "comment_required" }; + } + + let newStatus: ArtifactStatus = artifact.status; + let bumpRevision = false; + let auditAction = ""; + let writeReview = false; + let reviewDecision: ReviewDecision | null = null; + + switch (input.action) { + case "submit_for_review": { + newStatus = "in_review"; + bumpRevision = artifact.status === "changes_requested"; + auditAction = verb(input.entityType, "submit_for_review"); + break; + } + case "request_changes": { + newStatus = "changes_requested"; + auditAction = verb(input.entityType, "request_changes"); + writeReview = true; + reviewDecision = "request_changes"; + break; + } + case "reject": { + newStatus = "rejected"; + auditAction = verb(input.entityType, "reject"); + writeReview = true; + reviewDecision = "reject"; + break; + } + case "approve": { + await db.insertReview({ + entityType: input.entityType, + entityId: input.entityId, + entityRevision: artifact.revision, + reviewerId: input.actorId, + decision: "approve", + comment: input.comment?.trim() || null, + }); + const count = await countValidApprovals(db, { + entityType: input.entityType, + entityId: input.entityId, + revision: artifact.revision, + authorId: artifact.authorId, + }); + if (count >= PUBLISH_APPROVAL_THRESHOLD) { + await db.updateArtifactStatus({ + entityType: input.entityType, + entityId: input.entityId, + status: "published", + bumpRevision: false, + }); + await db.insertAudit({ + action: verb(input.entityType, "publish"), + targetType: tableName(input.entityType), + targetId: input.entityId, + payload: { approvalCount: count, revision: artifact.revision }, + }); + return { ok: true, newStatus: "published" }; + } + await db.insertAudit({ + action: verb(input.entityType, "approve"), + targetType: tableName(input.entityType), + targetId: input.entityId, + payload: { approvalCount: count, revision: artifact.revision }, + }); + return { ok: true, newStatus: artifact.status }; + } + case "cancel": { + newStatus = "cancelled"; + auditAction = verb(input.entityType, "cancel"); + break; + } + case "archive": { + newStatus = "archived"; + auditAction = verb(input.entityType, "archive"); + break; + } + case "close": { + newStatus = "closed"; + auditAction = verb(input.entityType, "close"); + break; + } + case "publish": { + return { ok: false, error: "invalid_transition" }; + } + } + + if (writeReview && reviewDecision) { + await db.insertReview({ + entityType: input.entityType, + entityId: input.entityId, + entityRevision: artifact.revision, + reviewerId: input.actorId, + decision: reviewDecision, + comment: input.comment?.trim() || null, + }); + } + + await db.updateArtifactStatus({ + entityType: input.entityType, + entityId: input.entityId, + status: newStatus, + bumpRevision, + }); + await db.insertAudit({ + action: auditAction, + targetType: tableName(input.entityType), + targetId: input.entityId, + payload: input.comment ? { comment: input.comment.trim() } : null, + }); + + return { ok: true, newStatus }; +} + +function verb(entityType: ArtifactEntityType, action: string): string { + return `${tableName(entityType)}.${action}`; +} + +function tableName(entityType: ArtifactEntityType): string { + switch (entityType) { + case "event": + return "events"; + case "announcement": + return "announcements"; + case "form": + return "forms"; + case "group": + return "groups"; + } +} From 0564f5be748a845c2aa56452b1899da46c058c74 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Fri, 22 May 2026 21:59:38 -0500 Subject: [PATCH 17/26] =?UTF-8?q?feat(api):=20lifecycle=20library=20?= =?UTF-8?q?=E2=80=94=20barrel=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/lib/lifecycle/index.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/api/src/lib/lifecycle/index.ts diff --git a/packages/api/src/lib/lifecycle/index.ts b/packages/api/src/lib/lifecycle/index.ts new file mode 100644 index 000000000..4442045bf --- /dev/null +++ b/packages/api/src/lib/lifecycle/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export * from "./transitions"; +export * from "./effectiveStatus"; +export * from "./approvals"; +export * from "./applyTransition"; From 84e827b18c16c06295f335f580198a462eb0d5fc Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Fri, 22 May 2026 22:17:37 -0500 Subject: [PATCH 18/26] feat(api): canEditArtifact policy (staff or author-on-draft/changes_requested) --- .../api/src/lib/policies/canEditArtifact.ts | 16 ++++ packages/api/src/lib/policies/index.ts | 1 + .../api/src/lib/policies/policies.test.ts | 87 ++++++++++++++++++- 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/lib/policies/canEditArtifact.ts diff --git a/packages/api/src/lib/policies/canEditArtifact.ts b/packages/api/src/lib/policies/canEditArtifact.ts new file mode 100644 index 000000000..93a65e2fc --- /dev/null +++ b/packages/api/src/lib/policies/canEditArtifact.ts @@ -0,0 +1,16 @@ +import type { ArtifactEntityType, ArtifactStatus } from "../lifecycle/types"; +import type { ActorContext } from "./types"; + +export const canEditArtifact = ( + a: ActorContext, + scope: { + entityType: ArtifactEntityType; + entityId: string; + status: ArtifactStatus; + authorId: string | null; + } +): boolean => { + if (a.systemTier >= 1) return true; + if (scope.authorId !== a.user.id) return false; + return scope.status === "draft" || scope.status === "changes_requested"; +}; diff --git a/packages/api/src/lib/policies/index.ts b/packages/api/src/lib/policies/index.ts index 2eec980f4..65d023011 100644 --- a/packages/api/src/lib/policies/index.ts +++ b/packages/api/src/lib/policies/index.ts @@ -10,3 +10,4 @@ export { canEditMembers } from "./canEditMembers"; export { canEditOrganizations } from "./canEditOrganizations"; export { canMergeOrganizations } from "./canMergeOrganizations"; export { canPromoteToRole } from "./canPromoteToRole"; +export { canEditArtifact } from "./canEditArtifact"; diff --git a/packages/api/src/lib/policies/policies.test.ts b/packages/api/src/lib/policies/policies.test.ts index 7b00bab75..a84f5c540 100644 --- a/packages/api/src/lib/policies/policies.test.ts +++ b/packages/api/src/lib/policies/policies.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, test } from "vitest"; import { canApproveVocab, canCreateGroup, @@ -185,3 +185,88 @@ describe("canCreateGroup", () => { expect(canCreateGroup(actor({ systemTier: 2 }))).toBe(true); }); }); + +import { canEditArtifact } from "./canEditArtifact"; + +const memberActor = (id = "u-member") => ({ + user: { id, memberId: "m", email: "e", role: "member" as const }, + systemTier: 0 as const, + leadershipPositions: [], + chairedGroupIds: new Set(), + chairedEventIds: new Set(), +}); + +const staffActor = (id = "u-staff") => ({ + ...memberActor(id), + user: { ...memberActor(id).user, role: "staff" as const }, + systemTier: 1 as const, +}); + +describe("canEditArtifact", () => { + test("staff can edit any artifact in any state", () => { + expect( + canEditArtifact(staffActor(), { + entityType: "event", + entityId: "e", + status: "in_review", + authorId: "someone-else", + }) + ).toBe(true); + expect( + canEditArtifact(staffActor(), { + entityType: "event", + entityId: "e", + status: "published", + authorId: "someone-else", + }) + ).toBe(true); + }); + + test("author can edit their own draft", () => { + const a = memberActor("author-1"); + expect( + canEditArtifact(a, { + entityType: "event", + entityId: "e", + status: "draft", + authorId: "author-1", + }) + ).toBe(true); + }); + + test("author can edit their own changes_requested", () => { + const a = memberActor("author-1"); + expect( + canEditArtifact(a, { + entityType: "event", + entityId: "e", + status: "changes_requested", + authorId: "author-1", + }) + ).toBe(true); + }); + + test("author cannot edit their own in_review (locked while reviewers look)", () => { + const a = memberActor("author-1"); + expect( + canEditArtifact(a, { + entityType: "event", + entityId: "e", + status: "in_review", + authorId: "author-1", + }) + ).toBe(false); + }); + + test("non-author non-staff member cannot edit someone else's artifact", () => { + const a = memberActor("u-1"); + expect( + canEditArtifact(a, { + entityType: "event", + entityId: "e", + status: "draft", + authorId: "author-2", + }) + ).toBe(false); + }); +}); From 31e28aef61d124b101ace7fc24477b842e42ea86 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Sat, 23 May 2026 07:13:02 -0700 Subject: [PATCH 19/26] feat(api): canReviewArtifact policy (staff + not-the-author) --- .../api/src/lib/policies/canReviewArtifact.ts | 6 +++++ packages/api/src/lib/policies/index.ts | 1 + .../api/src/lib/policies/policies.test.ts | 22 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 packages/api/src/lib/policies/canReviewArtifact.ts diff --git a/packages/api/src/lib/policies/canReviewArtifact.ts b/packages/api/src/lib/policies/canReviewArtifact.ts new file mode 100644 index 000000000..1eb3afbaf --- /dev/null +++ b/packages/api/src/lib/policies/canReviewArtifact.ts @@ -0,0 +1,6 @@ +import type { ActorContext } from "./types"; + +export const canReviewArtifact = ( + a: ActorContext, + scope: { authorId: string | null } +): boolean => a.systemTier >= 1 && a.user.id !== scope.authorId; diff --git a/packages/api/src/lib/policies/index.ts b/packages/api/src/lib/policies/index.ts index 65d023011..45d00817c 100644 --- a/packages/api/src/lib/policies/index.ts +++ b/packages/api/src/lib/policies/index.ts @@ -11,3 +11,4 @@ export { canEditOrganizations } from "./canEditOrganizations"; export { canMergeOrganizations } from "./canMergeOrganizations"; export { canPromoteToRole } from "./canPromoteToRole"; export { canEditArtifact } from "./canEditArtifact"; +export { canReviewArtifact } from "./canReviewArtifact"; diff --git a/packages/api/src/lib/policies/policies.test.ts b/packages/api/src/lib/policies/policies.test.ts index a84f5c540..9c60a9cee 100644 --- a/packages/api/src/lib/policies/policies.test.ts +++ b/packages/api/src/lib/policies/policies.test.ts @@ -270,3 +270,25 @@ describe("canEditArtifact", () => { ).toBe(false); }); }); + +import { canReviewArtifact } from "./canReviewArtifact"; + +describe("canReviewArtifact", () => { + test("staff can review any artifact they didn't author", () => { + expect( + canReviewArtifact(staffActor("staff-1"), { authorId: "author-1" }) + ).toBe(true); + }); + + test("staff cannot review their own artifact (self-promotion guard)", () => { + expect( + canReviewArtifact(staffActor("staff-1"), { authorId: "staff-1" }) + ).toBe(false); + }); + + test("member cannot review (insufficient tier)", () => { + expect( + canReviewArtifact(memberActor("m-1"), { authorId: "author-1" }) + ).toBe(false); + }); +}); From 2b5156ccda01319be8192cc16c634d0aaa82b4f6 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Sat, 23 May 2026 07:41:23 -0700 Subject: [PATCH 20/26] feat(api): Drizzle adapter for LifecycleDb interface --- .../api/src/lib/lifecycle/drizzleAdapter.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 packages/api/src/lib/lifecycle/drizzleAdapter.ts diff --git a/packages/api/src/lib/lifecycle/drizzleAdapter.ts b/packages/api/src/lib/lifecycle/drizzleAdapter.ts new file mode 100644 index 000000000..c46bbb9e9 --- /dev/null +++ b/packages/api/src/lib/lifecycle/drizzleAdapter.ts @@ -0,0 +1,231 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "../../db"; +import { + announcements, + artifactReviews, + auditLog, + events, + forms, +} from "../../db/schema"; +import type { LifecycleDb } from "./applyTransition"; + +/** + * Roles that can appear in the audit_log.actor_role column. + * Mirrors the user_role enum in schema/enums.ts. "admin" is the + * legacy value retained for migration compatibility. + */ +export type ActorRole = "member" | "staff" | "admin" | "super_admin"; + +/** + * Production implementation of LifecycleDb backed by Drizzle. + * + * The actor's id + role are bound at construction time and used + * exclusively for audit_log inserts; the lifecycle library itself + * has no concept of actors beyond the actorId on a transition. + */ +export function drizzleLifecycleDb( + db: Database, + actor: { id: string; role: ActorRole } +): LifecycleDb { + return { + async fetchArtifact(entityType, id) { + switch (entityType) { + case "event": { + const row = await db + .select({ + id: events.id, + status: events.status, + revision: events.revision, + authorId: events.authorId, + endDate: events.endDate, + }) + .from(events) + .where(eq(events.id, id)) + .limit(1) + .then((r) => r[0]); + if (!row) return null; + return { + id: row.id, + entityType: "event", + status: row.status, + revision: row.revision, + authorId: row.authorId, + effectiveStatusInputs: { endDate: row.endDate ?? null }, + }; + } + case "announcement": { + const row = await db + .select({ + id: announcements.id, + status: announcements.status, + revision: announcements.revision, + authorId: announcements.authorId, + expiresAt: announcements.expiresAt, + }) + .from(announcements) + .where(eq(announcements.id, id)) + .limit(1) + .then((r) => r[0]); + if (!row) return null; + return { + id: row.id, + entityType: "announcement", + status: row.status, + revision: row.revision, + authorId: row.authorId, + effectiveStatusInputs: { expiresAt: row.expiresAt ?? null }, + }; + } + case "form": { + const row = await db + .select({ + id: forms.id, + status: forms.status, + revision: forms.revision, + authorId: forms.authorId, + }) + .from(forms) + .where(eq(forms.id, id)) + .limit(1) + .then((r) => r[0]); + if (!row) return null; + return { + id: row.id, + entityType: "form", + status: row.status, + revision: row.revision, + authorId: row.authorId, + }; + } + case "group": + // Groups participate in the artifact_entity_type enum (for + // reviews/comments) but don't carry a lifecycle of their own. + return null; + } + }, + + async insertReview({ + entityType, + entityId, + entityRevision, + reviewerId, + decision, + comment, + }) { + await db.insert(artifactReviews).values({ + entityType, + entityId, + entityRevision, + reviewerId, + decision, + comment, + }); + }, + + async listApprovalsForRevision(entityType, entityId, revision) { + const rows = await db + .select({ reviewerId: artifactReviews.reviewerId }) + .from(artifactReviews) + .where( + and( + eq(artifactReviews.entityType, entityType), + eq(artifactReviews.entityId, entityId), + eq(artifactReviews.entityRevision, revision), + eq(artifactReviews.decision, "approve") + ) + ); + return rows; + }, + + async updateArtifactStatus({ entityType, entityId, status, bumpRevision }) { + const now = new Date(); + switch (entityType) { + case "event": { + if (bumpRevision) { + const current = await db + .select({ revision: events.revision }) + .from(events) + .where(eq(events.id, entityId)) + .limit(1) + .then((r) => r[0]); + await db + .update(events) + .set({ + status, + updatedAt: now, + revision: (current?.revision ?? 1) + 1, + }) + .where(eq(events.id, entityId)); + } else { + await db + .update(events) + .set({ status, updatedAt: now }) + .where(eq(events.id, entityId)); + } + return; + } + case "announcement": { + if (bumpRevision) { + const current = await db + .select({ revision: announcements.revision }) + .from(announcements) + .where(eq(announcements.id, entityId)) + .limit(1) + .then((r) => r[0]); + await db + .update(announcements) + .set({ + status, + updatedAt: now, + revision: (current?.revision ?? 1) + 1, + }) + .where(eq(announcements.id, entityId)); + } else { + await db + .update(announcements) + .set({ status, updatedAt: now }) + .where(eq(announcements.id, entityId)); + } + return; + } + case "form": { + if (bumpRevision) { + const current = await db + .select({ revision: forms.revision }) + .from(forms) + .where(eq(forms.id, entityId)) + .limit(1) + .then((r) => r[0]); + await db + .update(forms) + .set({ + status, + updatedAt: now, + revision: (current?.revision ?? 1) + 1, + }) + .where(eq(forms.id, entityId)); + } else { + await db + .update(forms) + .set({ status, updatedAt: now }) + .where(eq(forms.id, entityId)); + } + return; + } + case "group": + throw new Error("group is not a lifecycle entity type"); + } + }, + + async insertAudit({ action, targetType, targetId, payload }) { + await db.insert(auditLog).values({ + actorId: actor.id, + actorRole: actor.role, + action, + targetType, + targetId, + payload, + }); + }, + }; +} From 2bb071965a0bae31161c15c77782db2d5f2bef60 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Sat, 23 May 2026 08:13:18 -0700 Subject: [PATCH 21/26] feat(api): artifact comments sanitizer --- .../api/src/lib/artifacts/comments.test.ts | 26 +++++++++++++++++++ packages/api/src/lib/artifacts/comments.ts | 17 ++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/api/src/lib/artifacts/comments.test.ts create mode 100644 packages/api/src/lib/artifacts/comments.ts diff --git a/packages/api/src/lib/artifacts/comments.test.ts b/packages/api/src/lib/artifacts/comments.test.ts new file mode 100644 index 000000000..071e9dd10 --- /dev/null +++ b/packages/api/src/lib/artifacts/comments.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; +import { sanitizeCommentBody, COMMENT_MAX_LEN } from "./comments"; + +describe("sanitizeCommentBody", () => { + test("trims whitespace", () => { + const r = sanitizeCommentBody(" hello "); + expect(r.ok).toBe(true); + if (r.ok) expect(r.body).toBe("hello"); + }); + + test("rejects empty bodies", () => { + expect(sanitizeCommentBody("").ok).toBe(false); + expect(sanitizeCommentBody(" ").ok).toBe(false); + }); + + test("rejects bodies over the max length", () => { + const long = "x".repeat(COMMENT_MAX_LEN + 1); + expect(sanitizeCommentBody(long).ok).toBe(false); + }); + + test("accepts a normal comment", () => { + const r = sanitizeCommentBody("This needs a clearer title."); + expect(r.ok).toBe(true); + if (r.ok) expect(r.body).toBe("This needs a clearer title."); + }); +}); diff --git a/packages/api/src/lib/artifacts/comments.ts b/packages/api/src/lib/artifacts/comments.ts new file mode 100644 index 000000000..54f311590 --- /dev/null +++ b/packages/api/src/lib/artifacts/comments.ts @@ -0,0 +1,17 @@ +export const COMMENT_MAX_LEN = 4000; + +export type SanitizedComment = + | { ok: true; body: string } + | { ok: false; error: "empty" | "too_long" }; + +/** + * Normalize a comment body before insertion. Trims whitespace, rejects + * empties, enforces the max-length cap. Doesn't touch HTML — comments + * are rendered as plain text on the admin UI. + */ +export function sanitizeCommentBody(raw: string): SanitizedComment { + const trimmed = raw.trim(); + if (!trimmed) return { ok: false, error: "empty" }; + if (trimmed.length > COMMENT_MAX_LEN) return { ok: false, error: "too_long" }; + return { ok: true, body: trimmed }; +} From fc0672011d75a2a1184b25c44217ef044cf7e891 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Sat, 23 May 2026 09:20:06 -0700 Subject: [PATCH 22/26] feat(api): GET /admin/queue (UNION ALL across in_review artifacts) --- packages/api/src/routes/admin/index.ts | 2 + .../api/src/routes/admin/queue/index.test.ts | 57 +++++++++++++++++ packages/api/src/routes/admin/queue/index.ts | 62 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 packages/api/src/routes/admin/queue/index.test.ts create mode 100644 packages/api/src/routes/admin/queue/index.ts diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 1a4e53796..dc5f1a5fa 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -9,6 +9,7 @@ import { adminUsersRoute } from "./users"; import { adminOrganizationsRoute } from "./organizations"; import { adminVocabRoute } from "./vocab"; import { adminGroupsRoute } from "./groups"; +import { adminQueueRoute } from "./queue"; /** * Hono sub-app for /api/admin/*. Order matters: @@ -31,3 +32,4 @@ adminApi.route("/users", adminUsersRoute); adminApi.route("/vocab", adminVocabRoute); adminApi.route("/groups", adminGroupsRoute); adminApi.route("/organizations", adminOrganizationsRoute); +adminApi.route("/queue", adminQueueRoute); diff --git a/packages/api/src/routes/admin/queue/index.test.ts b/packages/api/src/routes/admin/queue/index.test.ts new file mode 100644 index 000000000..20345dd38 --- /dev/null +++ b/packages/api/src/routes/admin/queue/index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test, beforeAll, afterAll } from "vitest"; +// @ts-ignore — test/helpers lands in Task 20; file exists here for review, typecheck is suppressed until then +import { testApp, makeStaffActor, makeMemberActor, seedArtifacts } from "../../../test/helpers"; + +let cleanup: () => Promise; + +beforeAll(async () => { + cleanup = await seedArtifacts({ + events: [ + { id: "00000000-0000-0000-0000-0000000000a1", status: "in_review", revision: 1, authorId: "00000000-0000-0000-0000-0000000000b1", name: "Test Event", scope: "community", startDate: "2026-06-01" }, + { id: "00000000-0000-0000-0000-0000000000a2", status: "published", revision: 1, authorId: "00000000-0000-0000-0000-0000000000b1", name: "Already Published", scope: "public", startDate: "2026-07-01" }, + ], + announcements: [ + { id: "00000000-0000-0000-0000-0000000000a3", status: "in_review", revision: 1, authorId: "00000000-0000-0000-0000-0000000000b1", title: "Heads up", body: "..." }, + ], + forms: [ + { id: "00000000-0000-0000-0000-0000000000a4", status: "draft", revision: 1, authorId: "00000000-0000-0000-0000-0000000000b1", slug: "test-form", title: "Test Form", schema: {} }, + ], + }); +}); + +afterAll(async () => { + if (cleanup) await cleanup(); +}); + +describe("GET /admin/queue", () => { + test("requires staff actor", async () => { + const res = await testApp.request("/admin/queue", { + headers: { Authorization: makeMemberActor("00000000-0000-0000-0000-0000000000b2") }, + }); + expect(res.status).toBe(403); + }); + + test("returns in_review artifacts across all three types", async () => { + const res = await testApp.request("/admin/queue", { + headers: { Authorization: makeStaffActor("00000000-0000-0000-0000-0000000000b3") }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { rows: Array<{ id: string; entityType: string; status: string }> }; + const seededInReview = body.rows.filter((r) => + [ + "00000000-0000-0000-0000-0000000000a1", + "00000000-0000-0000-0000-0000000000a3", + ].includes(r.id) + ); + expect(seededInReview).toHaveLength(2); + }); + + test("supports filtering by entity type", async () => { + const res = await testApp.request("/admin/queue?type=event", { + headers: { Authorization: makeStaffActor("00000000-0000-0000-0000-0000000000b3") }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { rows: Array<{ entityType: string }> }; + expect(body.rows.every((r) => r.entityType === "event")).toBe(true); + }); +}); diff --git a/packages/api/src/routes/admin/queue/index.ts b/packages/api/src/routes/admin/queue/index.ts new file mode 100644 index 000000000..791bff6e1 --- /dev/null +++ b/packages/api/src/routes/admin/queue/index.ts @@ -0,0 +1,62 @@ +import { Hono } from "hono"; +import { sql } from "drizzle-orm"; +import { createDb } from "../../../db"; +import type { AppEnv } from "../../../types"; + +export const adminQueueRoute = new Hono(); + +/** + * GET /admin/queue + * + * Returns all in_review artifacts across events, announcements, forms + * via UNION ALL. Filters: type, scope. + */ +adminQueueRoute.get("/", async (c) => { + if (!c.env.DATABASE_URL) { + return c.json({ ok: false, error: "internal" }, 500); + } + const actor = c.get("actor"); + if (!actor || actor.systemTier < 1) { + return c.json({ ok: false, error: "forbidden" }, 403); + } + const db = createDb(c.env.DATABASE_URL); + + const typeFilter = c.req.query("type"); + const scopeFilter = c.req.query("scope"); + + const rows = await db.execute(sql` + SELECT entity_type, id, title, status, revision, scope, author_id, host_group_id, host_org_id, created_at + FROM ( + SELECT 'event'::text AS entity_type, id, name AS title, status::text, revision, scope::text, author_id, host_group_id, host_org_id, created_at + FROM events WHERE deleted_at IS NULL AND status = 'in_review' + UNION ALL + SELECT 'announcement'::text, id, title, status::text, revision, scope::text, author_id, host_group_id, NULL::uuid AS host_org_id, created_at + FROM announcements WHERE deleted_at IS NULL AND status = 'in_review' + UNION ALL + SELECT 'form'::text, id, title, status::text, revision, scope::text, author_id, host_group_id, NULL::uuid, created_at + FROM forms WHERE deleted_at IS NULL AND status = 'in_review' + ) q + WHERE (${typeFilter ?? null}::text IS NULL OR entity_type = ${typeFilter ?? null}::text) + AND (${scopeFilter ?? null}::text IS NULL OR scope = ${scopeFilter ?? null}::text) + ORDER BY created_at ASC + LIMIT 200 + `); + + const list = Array.isArray(rows) ? rows : (rows as { rows: Record[] }).rows; + + return c.json({ + ok: true, + rows: list.map((r: Record) => ({ + entityType: r.entity_type, + id: r.id, + title: r.title, + status: r.status, + revision: r.revision, + scope: r.scope, + authorId: r.author_id, + hostGroupId: r.host_group_id, + hostOrgId: r.host_org_id, + createdAt: r.created_at, + })), + }); +}); From d311ec664fd2c526867c72d9c4681bc865497382 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Sat, 23 May 2026 12:51:34 -0700 Subject: [PATCH 23/26] feat(api): GET /announcements/active-banner stub (returns null until Plan 3) --- packages/api/src/index.ts | 2 + packages/api/src/routes/announcements.ts | 69 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/api/src/routes/announcements.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 998b8d614..e5a7a2a56 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,6 +8,7 @@ import { webhooksRoute } from "./routes/webhooks"; import { publicGroupsRoute } from "./routes/groups"; import { organizationsRoute } from "./routes/organizations"; import { adminApi } from "./routes/admin"; +import { announcementsRoute } from "./routes/announcements"; import type { AppEnv } from "./types"; const app = new Hono(); @@ -52,6 +53,7 @@ app.route("/members", membersRoute); app.route("/vocab", vocabRoute); app.route("/groups", publicGroupsRoute); app.route("/organizations", organizationsRoute); +app.route("/announcements", announcementsRoute); app.route("/admin", adminApi); export default app; diff --git a/packages/api/src/routes/announcements.ts b/packages/api/src/routes/announcements.ts new file mode 100644 index 000000000..347eb9acd --- /dev/null +++ b/packages/api/src/routes/announcements.ts @@ -0,0 +1,69 @@ +import { Hono } from "hono"; +import { and, desc, eq, gt, isNull, or } from "drizzle-orm"; +import { createDb } from "../db"; +import { announcements, broadcastChannels, broadcastRequests } from "../db/schema"; +import type { AppEnv } from "../types"; + +export const announcementsRoute = new Hono(); + +/** + * GET /announcements/active-banner + * + * Returns at most one announcement: the most-recently-published row whose + * effective status is `published` (not expired) AND which has at least one + * broadcast_channels row with channel='site_banner' AND status='posted'. + * + * In v1, no announcements exist yet — the endpoint exists so the SPA's + * can mount without conditional code. Plan 3 wires the + * real query when announcements ship. + */ +announcementsRoute.get("/active-banner", async (c) => { + if (!c.env.DATABASE_URL) return c.json({ banner: null }); + const db = createDb(c.env.DATABASE_URL); + + const now = new Date(); + const row = await db + .select({ + id: announcements.id, + title: announcements.title, + body: announcements.body, + linkUrl: announcements.linkUrl, + expiresAt: announcements.expiresAt, + }) + .from(announcements) + .innerJoin( + broadcastRequests, + and( + eq(broadcastRequests.entityType, "announcement"), + eq(broadcastRequests.entityId, announcements.id) + ) + ) + .innerJoin( + broadcastChannels, + and( + eq(broadcastChannels.broadcastRequestId, broadcastRequests.id), + eq(broadcastChannels.channel, "site_banner"), + eq(broadcastChannels.status, "posted") + ) + ) + .where( + and( + eq(announcements.status, "published"), + isNull(announcements.deletedAt), + or(isNull(announcements.expiresAt), gt(announcements.expiresAt, now)) + ) + ) + .orderBy(desc(announcements.createdAt)) + .limit(1) + .then((r) => r[0]); + + if (!row) return c.json({ banner: null }); + return c.json({ + banner: { + id: row.id, + title: row.title, + body: row.body, + linkUrl: row.linkUrl, + }, + }); +}); From bb53be427f5671b31204e9d978566155263a5c1b Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Sat, 23 May 2026 13:15:03 -0700 Subject: [PATCH 24/26] test(api): integration test helpers + TEST_BYPASS_AUTH escape hatch --- packages/api/src/middleware/actorContext.ts | 28 ++++ packages/api/src/middleware/auth.ts | 13 ++ .../api/src/routes/admin/queue/index.test.ts | 1 - packages/api/src/test/helpers.ts | 126 ++++++++++++++++++ packages/api/src/types.ts | 6 + 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/test/helpers.ts diff --git a/packages/api/src/middleware/actorContext.ts b/packages/api/src/middleware/actorContext.ts index 9e33d66d6..67a90f27d 100644 --- a/packages/api/src/middleware/actorContext.ts +++ b/packages/api/src/middleware/actorContext.ts @@ -35,6 +35,34 @@ import type { AppEnv } from "../types"; */ export const requireActorContext = createMiddleware( async (c, next) => { + // Test escape hatch: when TEST_BYPASS_AUTH=1 and the Authorization + // header is in `test::` form, synthesize an ActorContext + // directly without DB lookups. Used by integration tests. + if (c.env.TEST_BYPASS_AUTH === "1") { + const header = c.req.header("Authorization") ?? ""; + const match = header.match(/^test:(staff|member|super_admin):(.+)$/); + if (match) { + const role = match[1] as "staff" | "member" | "super_admin"; + const userId = match[2]; + const tier = role === "member" ? 0 : role === "staff" ? 1 : 2; + const actor: ActorContext = { + user: { + id: userId, + memberId: userId, + email: `${userId}@test.local`, + role, + }, + systemTier: tier as 0 | 1 | 2, + leadershipPositions: [], + chairedGroupIds: new Set(), + chairedEventIds: new Set(), + }; + c.set("actor", actor); + await next(); + return; + } + } + const workosId = c.get("workosUserId"); if (!c.env.DATABASE_URL) { return c.json({ ok: false, error: "internal" }, 500); diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index 1852dd247..ba798252a 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -27,6 +27,19 @@ export function getJwks(clientId: string): JWKS { * shape is stable in production. */ export const requireAuth = createMiddleware(async (c, next) => { + // Test escape hatch: when TEST_BYPASS_AUTH=1, accept any Authorization + // header of the form `test::` and stash the userId in + // workosUserId so requireActorContext can pick it up. + if (c.env.TEST_BYPASS_AUTH === "1") { + const header = c.req.header("Authorization") ?? ""; + const match = header.match(/^test:(staff|member|super_admin):(.+)$/); + if (match) { + c.set("workosUserId", match[2]); + await next(); + return; + } + } + const header = c.req.header("Authorization"); if (!header || !header.startsWith("Bearer ")) { throw new HTTPException(401, { diff --git a/packages/api/src/routes/admin/queue/index.test.ts b/packages/api/src/routes/admin/queue/index.test.ts index 20345dd38..b9be6013e 100644 --- a/packages/api/src/routes/admin/queue/index.test.ts +++ b/packages/api/src/routes/admin/queue/index.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test, beforeAll, afterAll } from "vitest"; -// @ts-ignore — test/helpers lands in Task 20; file exists here for review, typecheck is suppressed until then import { testApp, makeStaffActor, makeMemberActor, seedArtifacts } from "../../../test/helpers"; let cleanup: () => Promise; diff --git a/packages/api/src/test/helpers.ts b/packages/api/src/test/helpers.ts new file mode 100644 index 000000000..0bddfcada --- /dev/null +++ b/packages/api/src/test/helpers.ts @@ -0,0 +1,126 @@ +import { neon } from "@neondatabase/serverless"; +import app from "../index"; + +/** + * Test helpers for integration tests that touch the real DB. + * + * Auth: actors are stubbed via Authorization header values of the form + * `test::`. Requires TEST_BYPASS_AUTH=1 in the env; + * the middleware bypass in actorContext.ts and auth.ts will recognize + * these and synthesize an actor directly. + * + * Seeding: the artifact INSERTs deliberately omit `author_id`. The FK + * is ON DELETE SET NULL on a nullable column, so leaving it NULL keeps + * tests independent from seeded users. (Option B from the Task 20 plan.) + */ + +/** + * Wraps `app.request()` and supplies a Bindings object derived from + * `process.env` (Hono's Workers entrypoint receives env per-request, + * so under Node we have to thread it through manually). Always sets + * TEST_BYPASS_AUTH=1 so the middleware bypass kicks in regardless of + * what was on the parent shell. + */ +export const testApp = { + request(input: string, init?: RequestInit): Promise { + const env = { + ...process.env, + TEST_BYPASS_AUTH: "1", + }; + return Promise.resolve(app.request(input, init, env)); + }, +}; + +export function makeStaffActor(userId: string): string { + return `test:staff:${userId}`; +} + +export function makeMemberActor(userId: string): string { + return `test:member:${userId}`; +} + +export function makeSuperAdminActor(userId: string): string { + return `test:super_admin:${userId}`; +} + +interface SeedInput { + events?: Array<{ + id: string; + status: string; + revision: number; + authorId: string; + name: string; + scope: string; + startDate: string; + endDate?: string; + }>; + announcements?: Array<{ + id: string; + status: string; + revision: number; + authorId: string; + title: string; + body: string; + expiresAt?: string; + }>; + forms?: Array<{ + id: string; + status: string; + revision: number; + authorId: string; + slug: string; + title: string; + schema: unknown; + }>; +} + +export async function seedArtifacts( + input: SeedInput +): Promise<() => Promise> { + const sql = neon(process.env.DATABASE_URL!); + const insertedEventIds = (input.events ?? []).map((e) => e.id); + const insertedAnnouncementIds = (input.announcements ?? []).map((a) => a.id); + const insertedFormIds = (input.forms ?? []).map((f) => f.id); + + for (const e of input.events ?? []) { + // author_id deliberately omitted (see file header). + await sql` + INSERT INTO events (id, slug, name, type, start_date, end_date, status, revision, scope) + VALUES ( + ${e.id}::uuid, ${e.id + "-slug"}, ${e.name}, 'other'::event_type, + ${e.startDate}::date, ${e.endDate ?? null}::date, + ${e.status}::artifact_status, ${e.revision}, ${e.scope}::artifact_scope + ) + `; + } + for (const a of input.announcements ?? []) { + await sql` + INSERT INTO announcements (id, status, revision, title, body, expires_at) + VALUES ( + ${a.id}::uuid, ${a.status}::artifact_status, ${a.revision}, + ${a.title}, ${a.body}, ${a.expiresAt ?? null} + ) + `; + } + for (const f of input.forms ?? []) { + await sql` + INSERT INTO forms (id, slug, title, schema, status, revision) + VALUES ( + ${f.id}::uuid, ${f.slug}, ${f.title}, ${JSON.stringify(f.schema)}::jsonb, + ${f.status}::artifact_status, ${f.revision} + ) + `; + } + + return async () => { + if (insertedEventIds.length) { + await sql`DELETE FROM events WHERE id = ANY(${insertedEventIds}::uuid[])`; + } + if (insertedAnnouncementIds.length) { + await sql`DELETE FROM announcements WHERE id = ANY(${insertedAnnouncementIds}::uuid[])`; + } + if (insertedFormIds.length) { + await sql`DELETE FROM forms WHERE id = ANY(${insertedFormIds}::uuid[])`; + } + }; +} diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 38a8ca862..3c28c6765 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -31,6 +31,12 @@ export type Bindings = { * configured" so the gate logic stays uniform across environments. */ ORGANIZATION_LOGOS_PUBLIC_URL: string; + /** + * Set to "1" in integration tests to enable the auth/actorContext + * middleware bypass that recognizes `test::` headers. + * Never set in production. + */ + TEST_BYPASS_AUTH?: string; }; export type Variables = { From a59dbbe20c32c9c37a103437ad0f8be8c85cbed4 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Sat, 23 May 2026 13:47:35 -0700 Subject: [PATCH 25/26] =?UTF-8?q?test(api):=20integration=20smoke=20for=20?= =?UTF-8?q?lifecycle=20happy=20path=20(submit=20=E2=86=92=202x=20approve?= =?UTF-8?q?=20=E2=86=92=20publish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/lib/lifecycle/integration.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 packages/api/src/lib/lifecycle/integration.test.ts diff --git a/packages/api/src/lib/lifecycle/integration.test.ts b/packages/api/src/lib/lifecycle/integration.test.ts new file mode 100644 index 000000000..2ca8a2ced --- /dev/null +++ b/packages/api/src/lib/lifecycle/integration.test.ts @@ -0,0 +1,114 @@ +import { beforeAll, afterAll, describe, expect, test } from "vitest"; +import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-http"; +import * as schema from "../../db/schema"; +import { applyTransition } from "./applyTransition"; +import { drizzleLifecycleDb } from "./drizzleAdapter"; + +const sql = neon(process.env.DATABASE_URL!); +const db = drizzle(sql, { schema }); + +const TEST_EVENT_ID = "00000000-0000-0000-0000-000000000a01"; +const TEST_AUTHOR_ID = "00000000-0000-0000-0000-000000000a02"; +const TEST_REVIEWER_1 = "00000000-0000-0000-0000-000000000a03"; +const TEST_REVIEWER_2 = "00000000-0000-0000-0000-000000000a04"; + +beforeAll(async () => { + // Cleanup any prior runs + await sql`DELETE FROM artifact_reviews WHERE entity_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM users WHERE id IN (${TEST_AUTHOR_ID}::uuid, ${TEST_REVIEWER_1}::uuid, ${TEST_REVIEWER_2}::uuid)`; + + // Seed three users — workos_id and member_id are NOT NULL UNIQUE; reuse the user id as a unique value for both. + for (const [id, role] of [ + [TEST_AUTHOR_ID, "member"], + [TEST_REVIEWER_1, "staff"], + [TEST_REVIEWER_2, "staff"], + ] as const) { + await sql` + INSERT INTO users (id, workos_id, member_id, email, role) + VALUES ( + ${id}::uuid, + ${"test-workos-" + id}, + ${"test-member-" + id}, + ${id + "@test.local"}, + ${role}::user_role + ) + `; + } + + // Seed a draft event + await sql` + INSERT INTO events (id, slug, name, type, start_date, status, revision, author_id, scope) + VALUES ( + ${TEST_EVENT_ID}::uuid, ${'integration-test-event-' + Date.now()}, 'Integration Test Event', + 'workshop'::event_type, '2099-12-31'::date, + 'draft'::artifact_status, 1, ${TEST_AUTHOR_ID}::uuid, 'community'::artifact_scope + ) + `; +}); + +afterAll(async () => { + await sql`DELETE FROM artifact_reviews WHERE entity_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM users WHERE id IN (${TEST_AUTHOR_ID}::uuid, ${TEST_REVIEWER_1}::uuid, ${TEST_REVIEWER_2}::uuid)`; +}); + +describe("artifact lifecycle integration", () => { + test("member submits, two staff approve, event publishes", async () => { + // 1. Author submits + { + const lifecycleDb = drizzleLifecycleDb(db, { id: TEST_AUTHOR_ID, role: "member" }); + const result = await applyTransition(lifecycleDb, { + entityType: "event", + entityId: TEST_EVENT_ID, + action: "submit_for_review", + actorId: TEST_AUTHOR_ID, + }); + expect(result.ok).toBe(true); + } + + // 2. First reviewer approves + { + const lifecycleDb = drizzleLifecycleDb(db, { id: TEST_REVIEWER_1, role: "staff" }); + const result = await applyTransition(lifecycleDb, { + entityType: "event", + entityId: TEST_EVENT_ID, + action: "approve", + actorId: TEST_REVIEWER_1, + }); + expect(result.ok).toBe(true); + } + + // Status should still be in_review + const afterFirst = await sql`SELECT status FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + expect(afterFirst[0].status).toBe("in_review"); + + // 3. Second reviewer approves — should publish + { + const lifecycleDb = drizzleLifecycleDb(db, { id: TEST_REVIEWER_2, role: "staff" }); + const result = await applyTransition(lifecycleDb, { + entityType: "event", + entityId: TEST_EVENT_ID, + action: "approve", + actorId: TEST_REVIEWER_2, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.newStatus).toBe("published"); + } + + const afterSecond = await sql`SELECT status FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + expect(afterSecond[0].status).toBe("published"); + + // Audit assertions + const audits = await sql` + SELECT action FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid ORDER BY created_at + `; + const actions = audits.map((a) => a.action); + expect(actions).toContain("events.submit_for_review"); + expect(actions).toContain("events.approve"); + expect(actions).toContain("events.publish"); + }); +}); From 351f8b4766ae708a265678541789a4d656570689 Mon Sep 17 00:00:00 2001 From: Cordero Core Date: Sat, 23 May 2026 20:05:26 -0700 Subject: [PATCH 26/26] fix(api): lifecycle follow-ups (skip integration without DB, atomic revision bump, document non-transactional) --- .../api/src/lib/lifecycle/applyTransition.ts | 11 ++- .../api/src/lib/lifecycle/drizzleAdapter.ts | 26 +----- .../api/src/lib/lifecycle/integration.test.ts | 91 +++++++++++-------- .../api/src/routes/admin/queue/index.test.ts | 8 +- 4 files changed, 72 insertions(+), 64 deletions(-) diff --git a/packages/api/src/lib/lifecycle/applyTransition.ts b/packages/api/src/lib/lifecycle/applyTransition.ts index 8ebee2b43..23a934a3a 100644 --- a/packages/api/src/lib/lifecycle/applyTransition.ts +++ b/packages/api/src/lib/lifecycle/applyTransition.ts @@ -60,7 +60,16 @@ export type ApplyTransitionResult = }; /** - * Atomic transition: validates, persists, and audits in one call. + * Apply a lifecycle transition: validates, persists, and audits in one call. + * + * NOTE on atomicity: this function is *sequential*, not *transactional*. The + * codebase uses Neon's HTTP driver (`drizzle-orm/neon-http`), which does not + * support `db.transaction()`. A crash between `insertReview` and + * `updateArtifactStatus` could leave a review row counted toward a future + * publish without flipping the artifact. At v1 admin write volumes this is + * acceptable; the audit_log provides forensic reconstruction. If concurrent + * write volumes grow, switch `createDb` to the WebSocket Pool driver and + * wrap the body of this function in `db.transaction(async tx => { ... })`. * * Map of action → review-decision-side-effect: * submit_for_review → no review row; sets in_review (bumps revision if from changes_requested) diff --git a/packages/api/src/lib/lifecycle/drizzleAdapter.ts b/packages/api/src/lib/lifecycle/drizzleAdapter.ts index c46bbb9e9..2708f5181 100644 --- a/packages/api/src/lib/lifecycle/drizzleAdapter.ts +++ b/packages/api/src/lib/lifecycle/drizzleAdapter.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import type { Database } from "../../db"; import { announcements, @@ -142,18 +142,12 @@ export function drizzleLifecycleDb( switch (entityType) { case "event": { if (bumpRevision) { - const current = await db - .select({ revision: events.revision }) - .from(events) - .where(eq(events.id, entityId)) - .limit(1) - .then((r) => r[0]); await db .update(events) .set({ status, updatedAt: now, - revision: (current?.revision ?? 1) + 1, + revision: sql`${events.revision} + 1`, }) .where(eq(events.id, entityId)); } else { @@ -166,18 +160,12 @@ export function drizzleLifecycleDb( } case "announcement": { if (bumpRevision) { - const current = await db - .select({ revision: announcements.revision }) - .from(announcements) - .where(eq(announcements.id, entityId)) - .limit(1) - .then((r) => r[0]); await db .update(announcements) .set({ status, updatedAt: now, - revision: (current?.revision ?? 1) + 1, + revision: sql`${announcements.revision} + 1`, }) .where(eq(announcements.id, entityId)); } else { @@ -190,18 +178,12 @@ export function drizzleLifecycleDb( } case "form": { if (bumpRevision) { - const current = await db - .select({ revision: forms.revision }) - .from(forms) - .where(eq(forms.id, entityId)) - .limit(1) - .then((r) => r[0]); await db .update(forms) .set({ status, updatedAt: now, - revision: (current?.revision ?? 1) + 1, + revision: sql`${forms.revision} + 1`, }) .where(eq(forms.id, entityId)); } else { diff --git a/packages/api/src/lib/lifecycle/integration.test.ts b/packages/api/src/lib/lifecycle/integration.test.ts index 2ca8a2ced..4609adf6b 100644 --- a/packages/api/src/lib/lifecycle/integration.test.ts +++ b/packages/api/src/lib/lifecycle/integration.test.ts @@ -5,58 +5,71 @@ import * as schema from "../../db/schema"; import { applyTransition } from "./applyTransition"; import { drizzleLifecycleDb } from "./drizzleAdapter"; -const sql = neon(process.env.DATABASE_URL!); -const db = drizzle(sql, { schema }); +const HAS_DB = !!process.env.DATABASE_URL; +const describeIfDb = HAS_DB ? describe : describe.skip; const TEST_EVENT_ID = "00000000-0000-0000-0000-000000000a01"; const TEST_AUTHOR_ID = "00000000-0000-0000-0000-000000000a02"; const TEST_REVIEWER_1 = "00000000-0000-0000-0000-000000000a03"; const TEST_REVIEWER_2 = "00000000-0000-0000-0000-000000000a04"; -beforeAll(async () => { - // Cleanup any prior runs - await sql`DELETE FROM artifact_reviews WHERE entity_id = ${TEST_EVENT_ID}::uuid`; - await sql`DELETE FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid`; - await sql`DELETE FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; - await sql`DELETE FROM users WHERE id IN (${TEST_AUTHOR_ID}::uuid, ${TEST_REVIEWER_1}::uuid, ${TEST_REVIEWER_2}::uuid)`; +// Lazily constructed inside beforeAll so collect-time doesn't require DATABASE_URL. +// Use the same generic narrowing (``) that `neon(url)` picks when +// called at module top level, so drizzle's `$client` type matches. +import type { NeonQueryFunction } from "@neondatabase/serverless"; +type Sql = NeonQueryFunction; +type Db = ReturnType>; +let sql: Sql; +let db: Db; - // Seed three users — workos_id and member_id are NOT NULL UNIQUE; reuse the user id as a unique value for both. - for (const [id, role] of [ - [TEST_AUTHOR_ID, "member"], - [TEST_REVIEWER_1, "staff"], - [TEST_REVIEWER_2, "staff"], - ] as const) { +describeIfDb("artifact lifecycle integration", () => { + beforeAll(async () => { + sql = neon(process.env.DATABASE_URL!) as Sql; + db = drizzle(sql, { schema }); + + // Cleanup any prior runs + await sql`DELETE FROM artifact_reviews WHERE entity_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM users WHERE id IN (${TEST_AUTHOR_ID}::uuid, ${TEST_REVIEWER_1}::uuid, ${TEST_REVIEWER_2}::uuid)`; + + // Seed three users — workos_id and member_id are NOT NULL UNIQUE; reuse the user id as a unique value for both. + for (const [id, role] of [ + [TEST_AUTHOR_ID, "member"], + [TEST_REVIEWER_1, "staff"], + [TEST_REVIEWER_2, "staff"], + ] as const) { + await sql` + INSERT INTO users (id, workos_id, member_id, email, role) + VALUES ( + ${id}::uuid, + ${"test-workos-" + id}, + ${"test-member-" + id}, + ${id + "@test.local"}, + ${role}::user_role + ) + `; + } + + // Seed a draft event await sql` - INSERT INTO users (id, workos_id, member_id, email, role) + INSERT INTO events (id, slug, name, type, start_date, status, revision, author_id, scope) VALUES ( - ${id}::uuid, - ${"test-workos-" + id}, - ${"test-member-" + id}, - ${id + "@test.local"}, - ${role}::user_role + ${TEST_EVENT_ID}::uuid, ${'integration-test-event-' + Date.now()}, 'Integration Test Event', + 'workshop'::event_type, '2099-12-31'::date, + 'draft'::artifact_status, 1, ${TEST_AUTHOR_ID}::uuid, 'community'::artifact_scope ) `; - } - - // Seed a draft event - await sql` - INSERT INTO events (id, slug, name, type, start_date, status, revision, author_id, scope) - VALUES ( - ${TEST_EVENT_ID}::uuid, ${'integration-test-event-' + Date.now()}, 'Integration Test Event', - 'workshop'::event_type, '2099-12-31'::date, - 'draft'::artifact_status, 1, ${TEST_AUTHOR_ID}::uuid, 'community'::artifact_scope - ) - `; -}); + }); -afterAll(async () => { - await sql`DELETE FROM artifact_reviews WHERE entity_id = ${TEST_EVENT_ID}::uuid`; - await sql`DELETE FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid`; - await sql`DELETE FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; - await sql`DELETE FROM users WHERE id IN (${TEST_AUTHOR_ID}::uuid, ${TEST_REVIEWER_1}::uuid, ${TEST_REVIEWER_2}::uuid)`; -}); + afterAll(async () => { + if (!sql) return; + await sql`DELETE FROM artifact_reviews WHERE entity_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM audit_log WHERE target_id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM events WHERE id = ${TEST_EVENT_ID}::uuid`; + await sql`DELETE FROM users WHERE id IN (${TEST_AUTHOR_ID}::uuid, ${TEST_REVIEWER_1}::uuid, ${TEST_REVIEWER_2}::uuid)`; + }); -describe("artifact lifecycle integration", () => { test("member submits, two staff approve, event publishes", async () => { // 1. Author submits { diff --git a/packages/api/src/routes/admin/queue/index.test.ts b/packages/api/src/routes/admin/queue/index.test.ts index b9be6013e..490aa1a70 100644 --- a/packages/api/src/routes/admin/queue/index.test.ts +++ b/packages/api/src/routes/admin/queue/index.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test, beforeAll, afterAll } from "vitest"; import { testApp, makeStaffActor, makeMemberActor, seedArtifacts } from "../../../test/helpers"; -let cleanup: () => Promise; +const HAS_DB = !!process.env.DATABASE_URL; +const describeIfDb = HAS_DB ? describe : describe.skip; + +let cleanup: (() => Promise) | undefined; beforeAll(async () => { + if (!HAS_DB) return; cleanup = await seedArtifacts({ events: [ { id: "00000000-0000-0000-0000-0000000000a1", status: "in_review", revision: 1, authorId: "00000000-0000-0000-0000-0000000000b1", name: "Test Event", scope: "community", startDate: "2026-06-01" }, @@ -22,7 +26,7 @@ afterAll(async () => { if (cleanup) await cleanup(); }); -describe("GET /admin/queue", () => { +describeIfDb("GET /admin/queue", () => { test("requires staff actor", async () => { const res = await testApp.request("/admin/queue", { headers: { Authorization: makeMemberActor("00000000-0000-0000-0000-0000000000b2") },