feat: forum, polymorphic comments, custom tiers, multi-invite, secrets encryption

Big feature batch driven by the super-audit findings and the user's
follow-up requests.

Schema migration 021 (folded into schema.sql Section 13):
- forum_threads + polymorphic comments table keyed on (entity_type,
  entity_id). Entity types: forum_thread, proposal, incident, policy.
  Trigger maintains forum_threads.comment_count and last_comment_at.
- space_tiers per-space with monthly_price_cents, billing_cadence,
  is_system, sort_order. Trigger seeds plus/basic/associate on every
  space INSERT and backfills existing spaces. space_members.tier_id FK
  added and backfilled from the legacy enum value.
- space_role_labels (rename/recolor built-in roles per space).
  space_custom_roles + space_member_custom_roles for non-privileged
  extra labels. RLS gates by admin/board.
- space_invites: multi-code invites with expires_at, max_uses,
  uses_count, is_enabled. Legacy spaces.invite_code backfilled as a
  permanent enabled invite.
- secrets.encrypted_value bytea + encryption_version. Secrets RLS
  tightened. New comms_channels INSERT policy: any member can create;
  default channels are protected from deletion.
- knowledge_base.render_markdown boolean (default true).

Secrets encryption-at-rest:
- AES-256-GCM in lib/secrets/crypto.ts. Master key from
  SECRETS_ENCRYPTION_KEY env (64 hex chars).
- New revealSecret(id) server action: only path to plaintext, writes
  secret.revealed to activity_log.
- /ops/page.tsx no longer selects value or encrypted_value on the list
  endpoint - the original screenshot bug where ciphertext was already
  in the page bundle before "Reveal" is fixed.

UI:
- /forum index, /forum/new, /forum/[id]. Pin/lock/delete actions for
  admin/board.
- CommentThread component embedded on /proposals/[id], /incidents/[id],
  /policies/[slug]. Markdown rendering via react-markdown + remark-gfm.
- /comms sidebar: inline "+ New channel" form.
- /ops KB: row click opens markdown-rendering viewer modal.
- /settings: new Tiers tab and Invites tab. Inline price edit and
  archive/delete on tiers; copy/enable/disable/delete on invites.
- AtlasLogo replaces the terminal icon on the landing nav and footer.
  public/logo.svg wired as the favicon in app/layout.tsx metadata.
- Sidebar has a Forum nav link.

Server actions added:
- lib/actions/forum.ts, comms.ts, tiers.ts, roles.ts, invites.ts.
- lib/actions/secrets.ts rewritten with updateSecret + revealSecret.

Auth flow:
- joinSpace honors codes from space_invites first (enabled, expiry,
  use cap), falls back to legacy spaces.invite_code, increments
  uses_count on success.

Env:
- SECRETS_ENCRYPTION_KEY documented in .env.example and set on the
  Droplet's /opt/hackerspace-ops/.env.production.

README: live site link added (https://hackerspace.sh).

266/266 unit tests pass. Production build clean. Migration 021 will be
applied on the next deploy via /opt/hackerspace-ops/deploy.sh's
idempotent migration runner.
This commit is contained in:
Moheeb Zara
2026-05-14 17:47:52 -07:00
parent 8ad1ca44b7
commit de51000584
35 changed files with 2981 additions and 93 deletions
+277
View File
@@ -1340,3 +1340,280 @@ DROP TRIGGER IF EXISTS trg_seed_default_areas ON public.spaces;
CREATE TRIGGER trg_seed_default_areas
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_areas();
-- =============================================================================
-- 13. Forum, polymorphic comments, custom tiers, custom role labels and
-- custom roles, multi-code invites, secrets encryption-at-rest, KB
-- markdown rendering flag.
-- Equivalent to scripts/021_forum_tiers_roles_invites_secrets.sql.
-- =============================================================================
DO $$ BEGIN
CREATE TYPE public.comment_entity_type AS ENUM ('forum_thread','proposal','incident','policy');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS public.forum_threads (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
body text,
category text NOT NULL DEFAULT 'general',
pinned boolean NOT NULL DEFAULT false,
locked boolean NOT NULL DEFAULT false,
comment_count integer NOT NULL DEFAULT 0,
last_comment_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_forum_threads_space ON public.forum_threads (space_id, last_comment_at DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_forum_threads_space_pinned ON public.forum_threads (space_id, pinned DESC, last_comment_at DESC NULLS LAST);
CREATE TABLE IF NOT EXISTS public.comments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
entity_type public.comment_entity_type NOT NULL,
entity_id uuid NOT NULL,
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
parent_id uuid REFERENCES public.comments(id) ON DELETE CASCADE,
body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 10000),
edited_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_comments_entity ON public.comments (entity_type, entity_id, created_at);
CREATE INDEX IF NOT EXISTS idx_comments_space ON public.comments (space_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comments_author ON public.comments (author_id);
CREATE OR REPLACE FUNCTION public.touch_thread_on_comment()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF (TG_OP = 'INSERT' AND NEW.entity_type = 'forum_thread') THEN
UPDATE public.forum_threads SET comment_count = comment_count + 1, last_comment_at = NEW.created_at, updated_at = now() WHERE id = NEW.entity_id;
ELSIF (TG_OP = 'DELETE' AND OLD.entity_type = 'forum_thread') THEN
UPDATE public.forum_threads SET comment_count = GREATEST(comment_count - 1, 0), updated_at = now() WHERE id = OLD.entity_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
DROP TRIGGER IF EXISTS trg_touch_thread_on_comment ON public.comments;
CREATE TRIGGER trg_touch_thread_on_comment
AFTER INSERT OR DELETE ON public.comments
FOR EACH ROW EXECUTE FUNCTION public.touch_thread_on_comment();
ALTER TABLE public.forum_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS forum_threads_select ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_insert ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_update ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_delete ON public.forum_threads;
CREATE POLICY forum_threads_select ON public.forum_threads FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY forum_threads_insert ON public.forum_threads FOR INSERT
WITH CHECK (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (author_id IS NULL OR author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid() AND space_id = forum_threads.space_id))
);
CREATE POLICY forum_threads_update ON public.forum_threads FOR UPDATE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
);
CREATE POLICY forum_threads_delete ON public.forum_threads FOR DELETE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])
);
DROP POLICY IF EXISTS comments_select ON public.comments;
DROP POLICY IF EXISTS comments_insert ON public.comments;
DROP POLICY IF EXISTS comments_update ON public.comments;
DROP POLICY IF EXISTS comments_delete ON public.comments;
CREATE POLICY comments_select ON public.comments FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY comments_insert ON public.comments FOR INSERT
WITH CHECK (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (author_id IS NULL OR author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid() AND space_id = comments.space_id))
);
CREATE POLICY comments_update ON public.comments FOR UPDATE
USING (author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()));
CREATE POLICY comments_delete ON public.comments FOR DELETE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
);
CREATE TABLE IF NOT EXISTS public.space_tiers (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
slug text NOT NULL CHECK (char_length(slug) BETWEEN 1 AND 50),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 100),
description text,
monthly_price_cents integer NOT NULL DEFAULT 0 CHECK (monthly_price_cents >= 0),
billing_cadence text NOT NULL DEFAULT 'monthly' CHECK (billing_cadence IN ('monthly','quarterly','annual','one_time','custom')),
is_system boolean NOT NULL DEFAULT false,
is_archived boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_space_tiers_space ON public.space_tiers (space_id, sort_order, name);
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS tier_id uuid REFERENCES public.space_tiers(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_space_members_tier_id ON public.space_members (tier_id);
CREATE OR REPLACE FUNCTION public.seed_default_tiers()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_tiers (space_id, slug, name, description, monthly_price_cents, billing_cadence, is_system, sort_order) VALUES
(NEW.id, 'plus', 'Plus', 'Full access including 24/7 keycard', 0, 'monthly', true, 0),
(NEW.id, 'basic', 'Basic', 'Standard member access during open hours', 0, 'monthly', true, 1),
(NEW.id, 'associate', 'Associate', 'Limited access for adjacent community members', 0, 'monthly', true, 2)
ON CONFLICT (space_id, slug) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_tiers ON public.spaces;
CREATE TRIGGER trg_seed_default_tiers
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_tiers();
ALTER TABLE public.space_tiers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tiers_select ON public.space_tiers;
DROP POLICY IF EXISTS tiers_insert ON public.space_tiers;
DROP POLICY IF EXISTS tiers_update ON public.space_tiers;
DROP POLICY IF EXISTS tiers_delete ON public.space_tiers;
CREATE POLICY tiers_select ON public.space_tiers FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY tiers_insert ON public.space_tiers FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY tiers_update ON public.space_tiers FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY tiers_delete ON public.space_tiers FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']) AND NOT is_system);
CREATE TABLE IF NOT EXISTS public.space_role_labels (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
role public.member_role NOT NULL,
display_name text,
description text,
color text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, role)
);
CREATE TABLE IF NOT EXISTS public.space_custom_roles (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
slug text NOT NULL CHECK (slug ~* '^[a-z0-9][a-z0-9_-]{0,49}$'),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 100),
description text,
color text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, slug)
);
CREATE TABLE IF NOT EXISTS public.space_member_custom_roles (
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
custom_role_id uuid NOT NULL REFERENCES public.space_custom_roles(id) ON DELETE CASCADE,
assigned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (member_id, custom_role_id)
);
CREATE INDEX IF NOT EXISTS idx_space_role_labels_space ON public.space_role_labels (space_id);
CREATE INDEX IF NOT EXISTS idx_space_custom_roles_space ON public.space_custom_roles (space_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_member_custom_roles_role ON public.space_member_custom_roles (custom_role_id);
ALTER TABLE public.space_role_labels ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_custom_roles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_member_custom_roles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS role_labels_select ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_insert ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_update ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_delete ON public.space_role_labels;
CREATE POLICY role_labels_select ON public.space_role_labels FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY role_labels_insert ON public.space_role_labels FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_labels_update ON public.space_role_labels FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_labels_delete ON public.space_role_labels FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
DROP POLICY IF EXISTS custom_roles_select ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_insert ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_update ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_delete ON public.space_custom_roles;
CREATE POLICY custom_roles_select ON public.space_custom_roles FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY custom_roles_insert ON public.space_custom_roles FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY custom_roles_update ON public.space_custom_roles FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY custom_roles_delete ON public.space_custom_roles FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
DROP POLICY IF EXISTS member_custom_roles_select ON public.space_member_custom_roles;
DROP POLICY IF EXISTS member_custom_roles_insert ON public.space_member_custom_roles;
DROP POLICY IF EXISTS member_custom_roles_delete ON public.space_member_custom_roles;
CREATE POLICY member_custom_roles_select ON public.space_member_custom_roles FOR SELECT
USING (member_id IN (SELECT id FROM public.space_members WHERE space_id IN (SELECT public.get_user_space_ids(auth.uid()))));
CREATE POLICY member_custom_roles_insert ON public.space_member_custom_roles FOR INSERT
WITH CHECK (member_id IN (SELECT sm.id FROM public.space_members sm WHERE public.user_has_role_in_space(auth.uid(), sm.space_id, ARRAY['admin','board'])));
CREATE POLICY member_custom_roles_delete ON public.space_member_custom_roles FOR DELETE
USING (member_id IN (SELECT sm.id FROM public.space_members sm WHERE public.user_has_role_in_space(auth.uid(), sm.space_id, ARRAY['admin','board'])));
CREATE TABLE IF NOT EXISTS public.space_invites (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
code text NOT NULL UNIQUE CHECK (char_length(code) BETWEEN 4 AND 32),
label text,
expires_at timestamptz,
max_uses integer CHECK (max_uses IS NULL OR max_uses > 0),
uses_count integer NOT NULL DEFAULT 0 CHECK (uses_count >= 0),
is_enabled boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_space_invites_space ON public.space_invites (space_id, is_enabled, expires_at);
CREATE INDEX IF NOT EXISTS idx_space_invites_code ON public.space_invites (code) WHERE is_enabled;
ALTER TABLE public.space_invites ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS invites_select ON public.space_invites;
DROP POLICY IF EXISTS invites_insert ON public.space_invites;
DROP POLICY IF EXISTS invites_update ON public.space_invites;
DROP POLICY IF EXISTS invites_delete ON public.space_invites;
CREATE POLICY invites_select ON public.space_invites FOR SELECT USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY invites_insert ON public.space_invites FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY invites_update ON public.space_invites FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY invites_delete ON public.space_invites FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS encrypted_value bytea;
ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS encryption_version smallint NOT NULL DEFAULT 0;
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS render_markdown boolean NOT NULL DEFAULT true;
-- comms_channels: any member can create one; non-default channels can be
-- updated/deleted by the creator or admin/board.
DROP POLICY IF EXISTS channels_insert ON public.comms_channels;
CREATE POLICY channels_insert ON public.comms_channels FOR INSERT
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
DROP POLICY IF EXISTS channels_update ON public.comms_channels;
CREATE POLICY channels_update ON public.comms_channels FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']) OR created_by = auth.uid());
DROP POLICY IF EXISTS channels_delete ON public.comms_channels;
CREATE POLICY channels_delete ON public.comms_channels FOR DELETE
USING (NOT is_default AND (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']) OR created_by = auth.uid()));
DROP TRIGGER IF EXISTS trg_forum_threads_touch ON public.forum_threads;
DROP TRIGGER IF EXISTS trg_space_tiers_touch ON public.space_tiers;
DROP TRIGGER IF EXISTS trg_space_role_labels_touch ON public.space_role_labels;
DROP TRIGGER IF EXISTS trg_space_custom_roles_touch ON public.space_custom_roles;
DROP TRIGGER IF EXISTS trg_space_invites_touch ON public.space_invites;
CREATE TRIGGER trg_forum_threads_touch BEFORE UPDATE ON public.forum_threads FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_tiers_touch BEFORE UPDATE ON public.space_tiers FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_role_labels_touch BEFORE UPDATE ON public.space_role_labels FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_custom_roles_touch BEFORE UPDATE ON public.space_custom_roles FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_invites_touch BEFORE UPDATE ON public.space_invites FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();