Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7d01fe4
docs(specs): events, announcements & forms subsystem design
cdcore09 May 20, 2026
c68da47
docs(plans): artifact subsystem foundation implementation plan
cdcore09 May 20, 2026
c58b733
feat(db): add artifact subsystem schema (events extension + announcem…
cdcore09 May 20, 2026
6a928a5
feat(db): add Drizzle enums for artifact subsystem
cdcore09 May 20, 2026
2a56ab1
refactor(db): drop redundant Enum suffix on broadcastChannel pgEnum
cdcore09 May 21, 2026
a14b634
feat(db): extend events schema with artifact columns
cdcore09 May 21, 2026
a2e0794
fix(db): add deleted_at partial-index predicate on events_status_idx
cdcore09 May 21, 2026
8271d66
feat(db): add announcements Drizzle schema
cdcore09 May 21, 2026
2e8a97c
feat(db): add forms + form_submissions Drizzle schema
cdcore09 May 21, 2026
17d87e8
feat(db): add polymorphic artifact tables (reviews, comments, broadca…
cdcore09 May 21, 2026
1bbe447
feat(db): register new schema modules in barrel
cdcore09 May 21, 2026
25dac30
feat(api): add lifecycle library types
cdcore09 May 21, 2026
eaa4bf6
feat(api): lifecycle library — valid transitions table
cdcore09 May 23, 2026
0655fc9
feat(api): lifecycle library — effectiveStatus read-time auto-transit…
cdcore09 May 23, 2026
f1be5b2
feat(api): lifecycle library — approval counter with self-promotion g…
cdcore09 May 23, 2026
74ea844
feat(api): lifecycle library — applyTransition orchestrator
cdcore09 May 23, 2026
0564f5b
feat(api): lifecycle library — barrel export
cdcore09 May 23, 2026
84e827b
feat(api): canEditArtifact policy (staff or author-on-draft/changes_r…
cdcore09 May 23, 2026
31e28ae
feat(api): canReviewArtifact policy (staff + not-the-author)
cdcore09 May 23, 2026
2b5156c
feat(api): Drizzle adapter for LifecycleDb interface
cdcore09 May 23, 2026
2bb0719
feat(api): artifact comments sanitizer
cdcore09 May 23, 2026
fc06720
feat(api): GET /admin/queue (UNION ALL across in_review artifacts)
cdcore09 May 23, 2026
d311ec6
feat(api): GET /announcements/active-banner stub (returns null until …
cdcore09 May 23, 2026
bb53be4
test(api): integration test helpers + TEST_BYPASS_AUTH escape hatch
cdcore09 May 23, 2026
a59dbbe
test(api): integration smoke for lifecycle happy path (submit → 2x ap…
cdcore09 May 23, 2026
351f8b4
fix(api): lifecycle follow-ups (skip integration without DB, atomic r…
cdcore09 May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

603 changes: 603 additions & 0 deletions docs/superpowers/specs/2026-05-20-events-announcements-forms-design.md

Large diffs are not rendered by default.

204 changes: 204 additions & 0 deletions packages/api/migrations/0022_artifact_subsystem.sql
Original file line number Diff line number Diff line change
@@ -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');
21 changes: 21 additions & 0 deletions packages/api/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
64 changes: 64 additions & 0 deletions packages/api/src/db/schema/announcements.ts
Original file line number Diff line number Diff line change
@@ -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],
}),
}));
Loading