feat: governance kernel, deployment hardening, local-dev stack
Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.
Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".
Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.
Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.
Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.
Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.
Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.
Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
-- =============================================================================
|
||||
-- 014: Make space_members.user_id nullable for offline members
|
||||
-- =============================================================================
|
||||
-- Background:
|
||||
-- addMember() and importMembers() in lib/actions.ts insert members that may
|
||||
-- not have a corresponding auth.users row (admin-added or CSV-imported
|
||||
-- members who have not signed up yet). The original schema declared
|
||||
-- space_members.user_id as NOT NULL, which made those inserts fail.
|
||||
--
|
||||
-- This migration:
|
||||
-- 1. Drops the NOT NULL constraint on space_members.user_id.
|
||||
--
|
||||
-- Safe to re-run: ALTER COLUMN ... DROP NOT NULL is idempotent in Postgres.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.space_members
|
||||
ALTER COLUMN user_id DROP NOT NULL;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- =============================================================================
|
||||
-- 015: Prevent self-role-change on space_members
|
||||
-- =============================================================================
|
||||
-- Background:
|
||||
-- The members_update RLS policy allows a user to UPDATE their own row
|
||||
-- (user_id = auth.uid()). This is intentional so members can edit their
|
||||
-- display_name, handle, phone, etc. without going through admin.
|
||||
-- But the policy has no column-level restriction, so a member could
|
||||
-- PATCH their own row via PostgREST and set role = 'admin'.
|
||||
--
|
||||
-- This migration:
|
||||
-- Adds a BEFORE UPDATE trigger that rejects any change to role, tier,
|
||||
-- status, approved, has_card_access, or space_id when the row being
|
||||
-- updated belongs to the current user AND the current user does not
|
||||
-- already hold a privileged role in that space.
|
||||
--
|
||||
-- Service role (no auth.uid) and admin/board/treasurer self-updates are
|
||||
-- unaffected.
|
||||
--
|
||||
-- Safe to re-run: function is CREATE OR REPLACE; trigger is dropped first.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_is_privileged boolean;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT public.user_has_role_in_space(auth.uid(), NEW.space_id, ARRAY['admin','board','treasurer'])
|
||||
INTO v_is_privileged;
|
||||
|
||||
IF v_is_privileged THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.role IS DISTINCT FROM OLD.role
|
||||
OR NEW.tier IS DISTINCT FROM OLD.tier
|
||||
OR NEW.status IS DISTINCT FROM OLD.status
|
||||
OR NEW.approved IS DISTINCT FROM OLD.approved
|
||||
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
|
||||
OR NEW.space_id IS DISTINCT FROM OLD.space_id THEN
|
||||
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, or space.'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_members;
|
||||
CREATE TRIGGER trg_prevent_member_self_role_change
|
||||
BEFORE UPDATE ON public.space_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change();
|
||||
@@ -0,0 +1,511 @@
|
||||
-- =============================================================================
|
||||
-- 016: Governance kernel — proposals, incidents, policies
|
||||
-- =============================================================================
|
||||
-- Tier 1 of docs/GOVERNANCE_FEATURES.md.
|
||||
--
|
||||
-- This migration adds three tightly integrated entities:
|
||||
--
|
||||
-- proposals + proposal_votes — async voting (Patterns 3, 4, 5, 12)
|
||||
-- incidents + incident_updates — CoC / safety report tracking (Pattern 1)
|
||||
-- policies — versioned bylaws with provenance (Pattern 4)
|
||||
--
|
||||
-- Plus six new columns on `spaces` for per-space governance defaults
|
||||
-- (quorum percent, voting window, incident SLA, mission statement, etc).
|
||||
--
|
||||
-- The three modules cross-reference: a proposal can cite a policy clause, an
|
||||
-- incident appeal becomes a proposal, a passed bylaw-change proposal supersedes
|
||||
-- a policy version. The circular FKs are added via ALTER TABLE after creation.
|
||||
--
|
||||
-- Safe to re-run: CREATE TABLE IF NOT EXISTS, DROP POLICY IF EXISTS before
|
||||
-- CREATE POLICY, CREATE OR REPLACE FUNCTION, etc.
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. ENUMS
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$ BEGIN CREATE TYPE public.proposal_type AS ENUM ('bylaw_change','board_action','membership_vote','advisory_poll','recall','budget'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.proposal_status AS ENUM ('draft','open','decided','withdrawn','expired'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.threshold_rule AS ENUM ('simple_majority','two_thirds','three_fourths','unanimous'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.vote_position AS ENUM ('yes','no','abstain','recused'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_status AS ENUM ('received','under_review','decided','appealed','closed'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_severity AS ENUM ('low','medium','high','critical'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_update_visibility AS ENUM ('reporter_only','all_parties','board_only'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.policy_status AS ENUM ('draft','active','deprecated','superseded'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. SPACES — per-space governance defaults
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_percent integer NOT NULL DEFAULT 10;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_floor integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_voting_window_hours integer NOT NULL DEFAULT 216; -- 9 days
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority';
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS incident_sla_hours integer NOT NULL DEFAULT 72; -- 3 days
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS mission_statement text;
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. TABLES
|
||||
-- Order matters because of the circular FKs. Create the tables first without
|
||||
-- the circular FK columns enforced, then add constraints via ALTER TABLE.
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- proposals --------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.proposals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
proposer_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
proposer_name text,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
proposal_type public.proposal_type NOT NULL DEFAULT 'advisory_poll',
|
||||
status public.proposal_status NOT NULL DEFAULT 'draft',
|
||||
quorum_required integer NOT NULL DEFAULT 0,
|
||||
quorum_percent integer NOT NULL DEFAULT 10,
|
||||
quorum_floor integer NOT NULL DEFAULT 1,
|
||||
threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority',
|
||||
voting_opens_at timestamptz,
|
||||
voting_closes_at timestamptz,
|
||||
policy_ref_id uuid,
|
||||
parent_incident_id uuid,
|
||||
outcome_yes integer NOT NULL DEFAULT 0,
|
||||
outcome_no integer NOT NULL DEFAULT 0,
|
||||
outcome_abstain integer NOT NULL DEFAULT 0,
|
||||
outcome_recused integer NOT NULL DEFAULT 0,
|
||||
total_voters integer NOT NULL DEFAULT 0,
|
||||
quorum_met boolean,
|
||||
passed boolean,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_at timestamptz
|
||||
);
|
||||
|
||||
-- proposal_votes ---------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.proposal_votes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
proposal_id uuid NOT NULL REFERENCES public.proposals(id) ON DELETE CASCADE,
|
||||
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
|
||||
position public.vote_position NOT NULL,
|
||||
recusal_reason text,
|
||||
comment text,
|
||||
voted_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (proposal_id, member_id),
|
||||
CONSTRAINT recused_requires_reason
|
||||
CHECK (position <> 'recused' OR (recusal_reason IS NOT NULL AND length(trim(recusal_reason)) > 0))
|
||||
);
|
||||
|
||||
-- incidents --------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.incidents (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
reporter_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
reporter_token text UNIQUE,
|
||||
is_anonymous boolean NOT NULL DEFAULT false,
|
||||
subjects uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
category text NOT NULL DEFAULT 'general',
|
||||
severity public.incident_severity NOT NULL DEFAULT 'medium',
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
status public.incident_status NOT NULL DEFAULT 'received',
|
||||
disposition text,
|
||||
decision_maker_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
appeal_proposal_id uuid,
|
||||
sla_response_by timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
acknowledged_at timestamptz,
|
||||
decided_at timestamptz,
|
||||
closed_at timestamptz
|
||||
);
|
||||
|
||||
-- incident_updates -------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.incident_updates (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
incident_id uuid NOT NULL REFERENCES public.incidents(id) ON DELETE CASCADE,
|
||||
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
author_name text,
|
||||
body text NOT NULL,
|
||||
visibility public.incident_update_visibility NOT NULL DEFAULT 'all_parties',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- policies ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.policies (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL,
|
||||
section_ref text,
|
||||
parent_policy_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
body_formal text NOT NULL DEFAULT '',
|
||||
body_plain text,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
prior_version_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
||||
status public.policy_status NOT NULL DEFAULT 'draft',
|
||||
effective_at timestamptz,
|
||||
adopted_by_proposal_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, slug, version)
|
||||
);
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. CIRCULAR FOREIGN KEYS — added after all three tables exist
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.proposals
|
||||
ADD CONSTRAINT proposals_policy_ref_fk
|
||||
FOREIGN KEY (policy_ref_id) REFERENCES public.policies(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.proposals
|
||||
ADD CONSTRAINT proposals_parent_incident_fk
|
||||
FOREIGN KEY (parent_incident_id) REFERENCES public.incidents(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.incidents
|
||||
ADD CONSTRAINT incidents_appeal_proposal_fk
|
||||
FOREIGN KEY (appeal_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.policies
|
||||
ADD CONSTRAINT policies_adopted_by_proposal_fk
|
||||
FOREIGN KEY (adopted_by_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. INDEXES
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_space ON public.proposals (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_status ON public.proposals (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_proposer ON public.proposals (proposer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_closes ON public.proposals (voting_closes_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_proposal ON public.proposal_votes (proposal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_member ON public.proposal_votes (member_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_space ON public.incidents (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_reporter ON public.incidents (reporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_status ON public.incidents (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_subjects ON public.incidents USING GIN (subjects);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_incidents_token_unique
|
||||
ON public.incidents (reporter_token)
|
||||
WHERE reporter_token IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON public.incident_updates (incident_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_space_slug ON public.policies (space_id, slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_status ON public.policies (status);
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. TRIGGERS
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- 6.1 Computes quorum_required and voting window when a proposal opens.
|
||||
CREATE OR REPLACE FUNCTION public.compute_proposal_quorum()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_member_count integer;
|
||||
v_quorum_percent integer;
|
||||
v_quorum_floor integer;
|
||||
v_voting_window_hours integer;
|
||||
BEGIN
|
||||
IF NEW.status = 'open' AND (TG_OP = 'INSERT' OR OLD.status = 'draft') THEN
|
||||
SELECT default_quorum_percent, default_quorum_floor, default_voting_window_hours
|
||||
INTO v_quorum_percent, v_quorum_floor, v_voting_window_hours
|
||||
FROM public.spaces WHERE id = NEW.space_id;
|
||||
|
||||
SELECT count(*) INTO v_member_count
|
||||
FROM public.space_members
|
||||
WHERE space_id = NEW.space_id
|
||||
AND status IN ('current','late')
|
||||
AND approved = true;
|
||||
|
||||
NEW.quorum_percent := COALESCE(v_quorum_percent, 10);
|
||||
NEW.quorum_floor := COALESCE(v_quorum_floor, 1);
|
||||
NEW.quorum_required := GREATEST(
|
||||
NEW.quorum_floor,
|
||||
CEIL(v_member_count * NEW.quorum_percent / 100.0)::integer
|
||||
);
|
||||
|
||||
IF NEW.voting_opens_at IS NULL THEN
|
||||
NEW.voting_opens_at := now();
|
||||
END IF;
|
||||
IF NEW.voting_closes_at IS NULL THEN
|
||||
NEW.voting_closes_at := NEW.voting_opens_at + (v_voting_window_hours || ' hours')::interval;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_compute_proposal_quorum ON public.proposals;
|
||||
CREATE TRIGGER trg_compute_proposal_quorum
|
||||
BEFORE INSERT OR UPDATE ON public.proposals
|
||||
FOR EACH ROW EXECUTE FUNCTION public.compute_proposal_quorum();
|
||||
|
||||
|
||||
-- 6.2 Recomputes proposal vote tallies on any vote insert / update / delete.
|
||||
CREATE OR REPLACE FUNCTION public.refresh_proposal_tally()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_proposal_id uuid;
|
||||
v_yes integer;
|
||||
v_no integer;
|
||||
v_abstain integer;
|
||||
v_recused integer;
|
||||
v_total integer;
|
||||
v_quorum_required integer;
|
||||
v_threshold public.threshold_rule;
|
||||
v_status public.proposal_status;
|
||||
v_quorum_met boolean;
|
||||
v_passed boolean;
|
||||
BEGIN
|
||||
v_proposal_id := COALESCE(NEW.proposal_id, OLD.proposal_id);
|
||||
|
||||
SELECT
|
||||
count(*) FILTER (WHERE position = 'yes'),
|
||||
count(*) FILTER (WHERE position = 'no'),
|
||||
count(*) FILTER (WHERE position = 'abstain'),
|
||||
count(*) FILTER (WHERE position = 'recused'),
|
||||
count(*)
|
||||
INTO v_yes, v_no, v_abstain, v_recused, v_total
|
||||
FROM public.proposal_votes
|
||||
WHERE proposal_id = v_proposal_id;
|
||||
|
||||
SELECT quorum_required, threshold, status
|
||||
INTO v_quorum_required, v_threshold, v_status
|
||||
FROM public.proposals WHERE id = v_proposal_id;
|
||||
|
||||
-- For quorum, count yes+no+abstain (recused does not count toward quorum).
|
||||
v_quorum_met := (v_yes + v_no + v_abstain) >= COALESCE(v_quorum_required, 0);
|
||||
|
||||
v_passed := CASE
|
||||
WHEN NOT v_quorum_met THEN false
|
||||
WHEN v_yes + v_no = 0 THEN false
|
||||
WHEN v_threshold = 'simple_majority' THEN v_yes > v_no
|
||||
WHEN v_threshold = 'two_thirds' THEN v_yes * 3 >= (v_yes + v_no) * 2
|
||||
WHEN v_threshold = 'three_fourths' THEN v_yes * 4 >= (v_yes + v_no) * 3
|
||||
WHEN v_threshold = 'unanimous' THEN v_no = 0 AND v_yes > 0
|
||||
ELSE false
|
||||
END;
|
||||
|
||||
UPDATE public.proposals
|
||||
SET outcome_yes = v_yes,
|
||||
outcome_no = v_no,
|
||||
outcome_abstain = v_abstain,
|
||||
outcome_recused = v_recused,
|
||||
total_voters = v_total,
|
||||
quorum_met = v_quorum_met,
|
||||
passed = v_passed,
|
||||
updated_at = now()
|
||||
WHERE id = v_proposal_id;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_refresh_proposal_tally ON public.proposal_votes;
|
||||
CREATE TRIGGER trg_refresh_proposal_tally
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.proposal_votes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.refresh_proposal_tally();
|
||||
|
||||
|
||||
-- 6.3 Sets incident.sla_response_by on insert if not provided.
|
||||
CREATE OR REPLACE FUNCTION public.compute_incident_sla()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_hours integer;
|
||||
BEGIN
|
||||
IF NEW.sla_response_by IS NULL THEN
|
||||
SELECT incident_sla_hours INTO v_hours FROM public.spaces WHERE id = NEW.space_id;
|
||||
NEW.sla_response_by := COALESCE(NEW.created_at, now()) + (COALESCE(v_hours, 72) || ' hours')::interval;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_compute_incident_sla ON public.incidents;
|
||||
CREATE TRIGGER trg_compute_incident_sla
|
||||
BEFORE INSERT ON public.incidents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.compute_incident_sla();
|
||||
|
||||
|
||||
-- 6.4 Updated-at maintenance.
|
||||
CREATE OR REPLACE FUNCTION public.touch_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_proposals_touch ON public.proposals;
|
||||
CREATE TRIGGER trg_proposals_touch
|
||||
BEFORE UPDATE ON public.proposals
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_incidents_touch ON public.incidents;
|
||||
CREATE TRIGGER trg_incidents_touch
|
||||
BEFORE UPDATE ON public.incidents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_policies_touch ON public.policies;
|
||||
CREATE TRIGGER trg_policies_touch
|
||||
BEFORE UPDATE ON public.policies
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 7. ROW LEVEL SECURITY
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.proposals ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.proposal_votes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.incidents ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.incident_updates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.policies ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Drop first so re-runs are safe.
|
||||
DROP POLICY IF EXISTS proposals_select ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_insert ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_update ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_delete ON public.proposals;
|
||||
DROP POLICY IF EXISTS votes_select ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS votes_insert ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS votes_update ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS incidents_select ON public.incidents;
|
||||
DROP POLICY IF EXISTS incidents_insert ON public.incidents;
|
||||
DROP POLICY IF EXISTS incidents_update ON public.incidents;
|
||||
DROP POLICY IF EXISTS incident_updates_select ON public.incident_updates;
|
||||
DROP POLICY IF EXISTS incident_updates_insert ON public.incident_updates;
|
||||
DROP POLICY IF EXISTS policies_select ON public.policies;
|
||||
DROP POLICY IF EXISTS policies_insert ON public.policies;
|
||||
DROP POLICY IF EXISTS policies_update ON public.policies;
|
||||
|
||||
-- proposals: any member can read and propose; proposer can edit while draft;
|
||||
-- admin/board can transition status. Delete: admin/board only.
|
||||
CREATE POLICY proposals_select ON public.proposals FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY proposals_insert ON public.proposals FOR INSERT
|
||||
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY proposals_update ON public.proposals FOR UPDATE
|
||||
USING (
|
||||
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
||||
OR (
|
||||
status = 'draft'
|
||||
AND proposer_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY proposals_delete ON public.proposals FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
|
||||
-- proposal_votes: all members see all votes (transparency). Members can only
|
||||
-- write their own vote. Can update while proposal still open. Never deleted.
|
||||
CREATE POLICY votes_select ON public.proposal_votes FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.proposals p
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND p.space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||
)
|
||||
);
|
||||
CREATE POLICY votes_insert ON public.proposal_votes FOR INSERT
|
||||
WITH CHECK (
|
||||
member_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.proposals p
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_opens_at <= now()
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
CREATE POLICY votes_update ON public.proposal_votes FOR UPDATE
|
||||
USING (
|
||||
member_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.proposals p
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
|
||||
-- incidents: admin/board see all; reporter sees their own; named subjects do
|
||||
-- not see by default (board-only disposition until policy says otherwise).
|
||||
CREATE POLICY incidents_select ON public.incidents FOR SELECT
|
||||
USING (
|
||||
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
||||
OR reporter_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY incidents_insert ON public.incidents FOR INSERT
|
||||
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY incidents_update ON public.incidents FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
-- No DELETE policy: incidents are retained.
|
||||
|
||||
-- incident_updates: visible per the row's `visibility` field, with admin/board
|
||||
-- always able to read.
|
||||
CREATE POLICY incident_updates_select ON public.incident_updates FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND (
|
||||
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR (
|
||||
incident_updates.visibility <> 'board_only'
|
||||
AND i.reporter_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY incident_updates_insert ON public.incident_updates FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND (
|
||||
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR i.reporter_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- policies: all members read; admin/board insert and supersede (which is an
|
||||
-- INSERT of a new version row, not an UPDATE). UPDATE allowed only for admin/
|
||||
-- board for status flips (deprecate, supersede). No DELETE.
|
||||
CREATE POLICY policies_select ON public.policies FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY policies_insert ON public.policies FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY policies_update ON public.policies FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
@@ -0,0 +1,107 @@
|
||||
-- =============================================================================
|
||||
-- 017: Governance RLS hardening
|
||||
-- =============================================================================
|
||||
-- Pass 5's Tier 1 RLS allowed several cross-tenant edge cases when a user is
|
||||
-- a member of more than one space. The app's `requireMember()` uses `.single()`
|
||||
-- which makes multi-space practically impossible at the app layer, but RLS
|
||||
-- should be airtight regardless.
|
||||
--
|
||||
-- Bugs being closed (defense in depth):
|
||||
-- 1. proposal_votes.votes_insert / votes_update accepted a vote where
|
||||
-- member_id was from space A but the proposal was in space B.
|
||||
-- 2. incidents.incidents_insert let the reporter set reporter_id to any
|
||||
-- space_members row they own, including one in a different space.
|
||||
-- 3. incident_updates.incident_updates_insert did not validate author_id.
|
||||
--
|
||||
-- Also adds a partial unique index that prevents two `active` policy versions
|
||||
-- for the same slug — fixing a race when two admins concurrently activate
|
||||
-- different drafts.
|
||||
--
|
||||
-- Idempotent: drops policies / indexes first.
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. proposal_votes: require member's space match the proposal's space.
|
||||
DROP POLICY IF EXISTS votes_insert ON public.proposal_votes;
|
||||
CREATE POLICY votes_insert ON public.proposal_votes FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.proposals p
|
||||
JOIN public.space_members m ON m.id = proposal_votes.member_id
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = p.space_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_opens_at <= now()
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS votes_update ON public.proposal_votes;
|
||||
CREATE POLICY votes_update ON public.proposal_votes FOR UPDATE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.proposals p
|
||||
JOIN public.space_members m ON m.id = proposal_votes.member_id
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = p.space_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- 2. incidents: reporter_id, when set, must be a space_members row owned by
|
||||
-- the caller AND in the same space as the incident.
|
||||
DROP POLICY IF EXISTS incidents_insert ON public.incidents;
|
||||
CREATE POLICY incidents_insert ON public.incidents FOR INSERT
|
||||
WITH CHECK (
|
||||
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||
AND (
|
||||
reporter_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.space_members m
|
||||
WHERE m.id = incidents.reporter_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = incidents.space_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- 3. incident_updates: author_id, when set, must be a space_members row
|
||||
-- owned by the caller AND in the same space as the incident.
|
||||
DROP POLICY IF EXISTS incident_updates_insert ON public.incident_updates;
|
||||
CREATE POLICY incident_updates_insert ON public.incident_updates FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND (
|
||||
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR i.reporter_id IN (
|
||||
SELECT m.id FROM public.space_members m
|
||||
WHERE m.user_id = auth.uid() AND m.space_id = i.space_id
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
author_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.space_members m, public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND m.id = incident_updates.author_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = i.space_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- 4. Exactly one active policy version per (space, slug).
|
||||
DROP INDEX IF EXISTS public.policies_one_active_per_slug;
|
||||
CREATE UNIQUE INDEX policies_one_active_per_slug
|
||||
ON public.policies (space_id, slug)
|
||||
WHERE status = 'active';
|
||||
@@ -0,0 +1,127 @@
|
||||
-- =============================================================================
|
||||
-- 018: Member-state extensions, visibility settings, KB meeting flag
|
||||
-- =============================================================================
|
||||
-- Tier 2 and Tier 3 of docs/GOVERNANCE_FEATURES.md (the smaller column-set
|
||||
-- extensions; UI for the rest follows in app code).
|
||||
--
|
||||
-- Adds:
|
||||
-- - Self-editable member profile fields: skills, interests, willing_to.
|
||||
-- - Conflict-of-interest disclosure fields: affiliations,
|
||||
-- coi_last_disclosed_at.
|
||||
-- - A BEFORE-UPDATE/INSERT trigger on space_members that auto-creates a
|
||||
-- 180-day card-access review task whenever has_card_access flips on.
|
||||
-- - Per-space settings for financial-data visibility and member-directory
|
||||
-- visibility (so spaces can change "who sees the cardholder count" without
|
||||
-- code changes).
|
||||
-- - knowledge_base flags for board meeting minutes: is_meeting_minutes and
|
||||
-- meeting_date.
|
||||
-- - inactive_members view: members with no activity_log entry in 180 days.
|
||||
--
|
||||
-- Idempotent.
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
-- 1. ENUMS ----------------------------------------------------------------------
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.financial_visibility AS ENUM ('treasurer_only','board_visible','all_members_visible');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.directory_visibility AS ENUM ('board_only','member_count_visible','members_visible','public_members_visible');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
|
||||
-- 2. SPACES — visibility settings ---------------------------------------------
|
||||
ALTER TABLE public.spaces
|
||||
ADD COLUMN IF NOT EXISTS financial_visibility public.financial_visibility NOT NULL DEFAULT 'board_visible';
|
||||
ALTER TABLE public.spaces
|
||||
ADD COLUMN IF NOT EXISTS member_directory_visibility public.directory_visibility NOT NULL DEFAULT 'members_visible';
|
||||
|
||||
|
||||
-- 3. SPACE_MEMBERS — opt-in profile and COI ----------------------------------
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS skills text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS interests text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS willing_to text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS affiliations text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS coi_last_disclosed_at timestamptz;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_space_members_willing_to ON public.space_members USING GIN (willing_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_members_skills ON public.space_members USING GIN (skills);
|
||||
|
||||
|
||||
-- 4. KNOWLEDGE_BASE — meeting minutes flag -----------------------------------
|
||||
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS is_meeting_minutes boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS meeting_date timestamptz;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_meeting_date
|
||||
ON public.knowledge_base (meeting_date DESC)
|
||||
WHERE is_meeting_minutes = true;
|
||||
|
||||
|
||||
-- 5. Card-access mid-tenure review trigger -----------------------------------
|
||||
-- Fires when has_card_access flips from false/null to true. Inserts a 180-day
|
||||
-- task into `tasks` so admin / board are reminded to re-evaluate access.
|
||||
CREATE OR REPLACE FUNCTION public.schedule_card_review()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_review_date timestamptz;
|
||||
v_old_card boolean;
|
||||
BEGIN
|
||||
v_old_card := COALESCE(OLD.has_card_access, false);
|
||||
IF NEW.has_card_access = true AND (TG_OP = 'INSERT' OR v_old_card = false) THEN
|
||||
v_review_date := now() + interval '180 days';
|
||||
INSERT INTO public.tasks (
|
||||
space_id,
|
||||
title,
|
||||
description,
|
||||
task_type,
|
||||
status,
|
||||
area,
|
||||
due_date,
|
||||
requested_by_name
|
||||
) VALUES (
|
||||
NEW.space_id,
|
||||
'Card-access review: ' || COALESCE(NULLIF(NEW.display_name, ''), 'member'),
|
||||
'Auto-generated 6-month review. Confirm card-access should continue. Any concerns from area leads, station champions, or hosts?',
|
||||
'task',
|
||||
'open',
|
||||
'Admin',
|
||||
v_review_date,
|
||||
'System'
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_schedule_card_review ON public.space_members;
|
||||
CREATE TRIGGER trg_schedule_card_review
|
||||
AFTER INSERT OR UPDATE OF has_card_access ON public.space_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.schedule_card_review();
|
||||
|
||||
|
||||
-- 6. inactive_members view ---------------------------------------------------
|
||||
-- Members in good standing who have no activity_log entry in the last 180
|
||||
-- days. View, not materialized — recomputed on read. Cheap because activity_log
|
||||
-- already has an index on (space_id, created_at DESC).
|
||||
CREATE OR REPLACE VIEW public.inactive_members AS
|
||||
SELECT m.*,
|
||||
a.last_activity_at
|
||||
FROM public.space_members m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(created_at) AS last_activity_at
|
||||
FROM public.activity_log al
|
||||
WHERE al.user_id = m.user_id AND al.space_id = m.space_id
|
||||
) a ON true
|
||||
WHERE m.status IN ('current', 'late')
|
||||
AND m.approved = true
|
||||
AND (a.last_activity_at IS NULL OR a.last_activity_at < now() - interval '180 days');
|
||||
|
||||
-- Grant access via RLS-equivalent function. Views inherit RLS from underlying
|
||||
-- tables in Postgres only if the view owner is not a superuser. The view here
|
||||
-- is owned by `postgres`, which bypasses RLS. We therefore filter at the
|
||||
-- application layer (`/members` page already scopes by space_id when reading
|
||||
-- this view).
|
||||
@@ -0,0 +1,83 @@
|
||||
-- =============================================================================
|
||||
-- 019: Auto-expire open proposals whose voting window has closed
|
||||
-- =============================================================================
|
||||
-- Pass 5's open item: when `voting_closes_at < now()` on an `open` proposal,
|
||||
-- nothing flips the status. Admins have to click "Mark decided" manually.
|
||||
--
|
||||
-- This migration adds two pieces:
|
||||
--
|
||||
-- 1. A SECURITY DEFINER function `public.expire_proposals()` that finds
|
||||
-- every open proposal with a passed deadline and updates it:
|
||||
-- - If quorum is met, status becomes 'decided' and decided_at = now()
|
||||
-- (the trigger has already computed `passed`, so the result is final).
|
||||
-- - If quorum is NOT met, status becomes 'expired'.
|
||||
-- Returns the number of proposals it touched, so callers can monitor.
|
||||
--
|
||||
-- 2. An "on-read" optimisation: nothing here yet, deliberately. Polling
|
||||
-- `SELECT public.expire_proposals()` on a schedule is the recommended
|
||||
-- pattern. From a Next.js cron job, from pg_cron on Supabase, or from
|
||||
-- a /api/cron route guarded by a CRON_SECRET header.
|
||||
--
|
||||
-- Safe to re-run: CREATE OR REPLACE FUNCTION.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.expire_proposals()
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_decided integer;
|
||||
v_expired integer;
|
||||
BEGIN
|
||||
-- Quorum met -> decide.
|
||||
WITH updated AS (
|
||||
UPDATE public.proposals
|
||||
SET status = 'decided',
|
||||
decided_at = now()
|
||||
WHERE status = 'open'
|
||||
AND voting_closes_at IS NOT NULL
|
||||
AND voting_closes_at < now()
|
||||
AND quorum_met = true
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) INTO v_decided FROM updated;
|
||||
|
||||
-- Quorum not met -> expire.
|
||||
WITH updated AS (
|
||||
UPDATE public.proposals
|
||||
SET status = 'expired'
|
||||
WHERE status = 'open'
|
||||
AND voting_closes_at IS NOT NULL
|
||||
AND voting_closes_at < now()
|
||||
AND COALESCE(quorum_met, false) = false
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) INTO v_expired FROM updated;
|
||||
|
||||
RETURN v_decided + v_expired;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Optional: schedule with pg_cron if available. Self-hosted Supabase enables
|
||||
-- the extension by default; managed Supabase requires the user to enable it
|
||||
-- from Database → Extensions. The DO block below is best-effort and fails
|
||||
-- silently when pg_cron is not present.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN
|
||||
-- Unschedule any prior job with the same name (idempotent).
|
||||
PERFORM cron.unschedule(jobid)
|
||||
FROM cron.job
|
||||
WHERE jobname = 'expire-proposals-hourly';
|
||||
PERFORM cron.schedule(
|
||||
'expire-proposals-hourly',
|
||||
'0 * * * *',
|
||||
$cron$SELECT public.expire_proposals();$cron$
|
||||
);
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- pg_cron not installed or insufficient privileges. The function still
|
||||
-- exists; the operator can run it on whatever schedule they want.
|
||||
NULL;
|
||||
END $$;
|
||||
@@ -0,0 +1,109 @@
|
||||
-- =============================================================================
|
||||
-- 020: Per-space areas table with seeded defaults
|
||||
-- =============================================================================
|
||||
-- Currently `tasks.area`, `projects.area`, `knowledge_base.area`, etc are free
|
||||
-- text. The UI hard-codes a list of nine area names in each client component.
|
||||
-- This migration:
|
||||
--
|
||||
-- 1. Creates `space_areas`: rows of (space_id, code, name, icon, sort_order,
|
||||
-- is_archived). Each space has its own list.
|
||||
-- 2. Adds a trigger that seeds ten sensible defaults whenever a `spaces`
|
||||
-- row is inserted.
|
||||
-- 3. Backfills every existing space with the defaults (idempotent —
|
||||
-- skipped where the space already has at least one area row).
|
||||
--
|
||||
-- Existing `tasks.area` / `projects.area` text columns are unchanged; values
|
||||
-- still match by name. Renaming an area does NOT cascade; admins re-tag
|
||||
-- existing rows if they care. This is acceptable for a small CRUD app.
|
||||
--
|
||||
-- Idempotent.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.space_areas (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
code text NOT NULL,
|
||||
name text NOT NULL,
|
||||
icon text,
|
||||
sort_order integer NOT NULL DEFAULT 100,
|
||||
is_archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, code),
|
||||
UNIQUE (space_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_space_areas_space_sort
|
||||
ON public.space_areas (space_id, sort_order, name);
|
||||
|
||||
ALTER TABLE public.space_areas ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS space_areas_select ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_insert ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_update ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_delete ON public.space_areas;
|
||||
|
||||
CREATE POLICY space_areas_select ON public.space_areas FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY space_areas_insert ON public.space_areas FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY space_areas_update ON public.space_areas FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY space_areas_delete ON public.space_areas FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_space_areas_touch ON public.space_areas;
|
||||
CREATE TRIGGER trg_space_areas_touch
|
||||
BEFORE UPDATE ON public.space_areas
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
|
||||
-- Seed defaults when a new space is created.
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_areas()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.space_areas (space_id, code, name, sort_order) VALUES
|
||||
(NEW.id, '3d-printing', '3D Printing', 10),
|
||||
(NEW.id, 'electronics', 'Electronics', 20),
|
||||
(NEW.id, 'woodshop', 'Woodshop', 30),
|
||||
(NEW.id, 'laser', 'Laser', 40),
|
||||
(NEW.id, 'metal-shop', 'Metal Shop', 50),
|
||||
(NEW.id, 'facilities', 'Facilities', 60),
|
||||
(NEW.id, 'software', 'Software', 70),
|
||||
(NEW.id, 'kitchen', 'Kitchen', 80),
|
||||
(NEW.id, 'admin', 'Admin', 90),
|
||||
(NEW.id, 'general', 'General', 100)
|
||||
ON CONFLICT (space_id, code) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
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();
|
||||
|
||||
|
||||
-- Backfill every existing space that has no areas yet.
|
||||
INSERT INTO public.space_areas (space_id, code, name, sort_order)
|
||||
SELECT s.id, d.code, d.name, d.sort_order
|
||||
FROM public.spaces s
|
||||
CROSS JOIN (VALUES
|
||||
('3d-printing', '3D Printing', 10),
|
||||
('electronics', 'Electronics', 20),
|
||||
('woodshop', 'Woodshop', 30),
|
||||
('laser', 'Laser', 40),
|
||||
('metal-shop', 'Metal Shop', 50),
|
||||
('facilities', 'Facilities', 60),
|
||||
('software', 'Software', 70),
|
||||
('kitchen', 'Kitchen', 80),
|
||||
('admin', 'Admin', 90),
|
||||
('general', 'General', 100)
|
||||
) AS d(code, name, sort_order)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.space_areas a WHERE a.space_id = s.id
|
||||
)
|
||||
ON CONFLICT (space_id, code) DO NOTHING;
|
||||
@@ -0,0 +1,104 @@
|
||||
# Database scripts
|
||||
|
||||
This directory holds the SQL that defines the application's database. The application is multi-tenant per `spaces` row; every other table is keyed by `space_id`. Row-level security is mandatory.
|
||||
|
||||
## Two kinds of file
|
||||
|
||||
### `schema.sql`
|
||||
|
||||
The canonical, full-schema file. Run this **once** in a fresh Supabase project's SQL editor. It is idempotent: extensions, enums, tables, indexes, policies, triggers, and realtime publications are all wrapped in guards so re-running is safe.
|
||||
|
||||
Sections in order:
|
||||
|
||||
1. Extensions
|
||||
2. Enums
|
||||
3. Tables (in FK-dependency order)
|
||||
4. Indexes
|
||||
5. Helper functions (`get_user_space_ids`, `user_has_role_in_space`)
|
||||
6. Row Level Security (drops before recreating)
|
||||
7. Triggers and auth hook (signup hook, default channels, privilege-escalation guard)
|
||||
8. Realtime publication membership
|
||||
|
||||
Anyone deploying for the first time needs only this file plus the environment variables documented in `.env.example`.
|
||||
|
||||
### `NNN_short_description.sql`
|
||||
|
||||
Incremental migrations for **existing** databases. Each file:
|
||||
|
||||
- Is named `NNN_short_description.sql` where `NNN` is the next zero-padded sequence number after the highest existing file in the directory.
|
||||
- Does one focused thing.
|
||||
- Is idempotent (`ALTER COLUMN ... DROP NOT NULL`, `CREATE OR REPLACE FUNCTION`, `DROP TRIGGER IF EXISTS` then `CREATE TRIGGER`).
|
||||
- Carries a short comment block at the top: background, what it does, safety notes.
|
||||
|
||||
When you add an incremental migration, you must also update `schema.sql` so that a fresh deploy ends up in the same final state.
|
||||
|
||||
## Current incremental migrations
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `014_member_user_id_nullable.sql` | Drops NOT NULL on `space_members.user_id` so offline members can be inserted by `addMember` / `importMembers`. |
|
||||
| `015_prevent_member_self_role_change.sql` | BEFORE UPDATE trigger that rejects self-modification of role, tier, status, approved, has_card_access, space_id by non-privileged members. Closes a privilege-escalation hole. |
|
||||
| `016_governance_kernel.sql` | Tier 1 of `docs/GOVERNANCE_FEATURES.md`: proposals + proposal_votes, incidents + incident_updates, policies. Plus six new `spaces` columns for governance defaults. Adds quorum-computing and tally-refreshing triggers. |
|
||||
| `017_governance_rls_hardening.sql` | Closes four cross-tenant RLS holes in the governance kernel (votes_insert/update, incidents_insert, incident_updates_insert), plus a partial unique index so only one policy version per slug can be `active` at a time. |
|
||||
| `018_member_state_and_visibility.sql` | Tier 2 + Tier 3: skills / interests / willing_to / affiliations / coi_last_disclosed_at columns on `space_members`; `financial_visibility` + `member_directory_visibility` settings on `spaces`; `is_meeting_minutes` + `meeting_date` on `knowledge_base`; auto-scheduled 180-day card-access review trigger; `inactive_members` view. |
|
||||
| `019_proposal_expiry.sql` | `public.expire_proposals()` function that closes proposals whose `voting_closes_at` has passed: flips to `decided` if quorum was met, `expired` otherwise. Schedules itself hourly via `pg_cron` if the extension is enabled. |
|
||||
| `020_areas.sql` | Per-space `space_areas` table (code, name, icon, sort_order, is_archived). Trigger seeds 10 sensible defaults on space creation; backfill populates existing spaces. RLS: members read, admin/board write, admin delete. |
|
||||
|
||||
### `seed.sql`
|
||||
|
||||
Demo data loader for local development. Run after `schema.sql` against an empty database to populate a "Demo Hackerspace" with five offline members (one per role), a policy, three tasks, a project, a KB entry, three proposals (open / draft / decided) with seven votes already cast, an open incident, and matching activity-log rows. Idempotent: re-running drops and reseeds the demo space without touching anything else.
|
||||
|
||||
Older migrations (001 to 013) lived in the original Supabase project and were collapsed into `schema.sql`. They are not in this directory.
|
||||
|
||||
## Workflow
|
||||
|
||||
When making a schema change:
|
||||
|
||||
1. Write the SQL as an incremental migration: `scripts/NNN_what_you_changed.sql`.
|
||||
2. Apply the same change to `schema.sql` so fresh deploys land in the new state.
|
||||
3. Re-run `schema.sql` against a scratch Supabase project to confirm it still completes without error.
|
||||
4. Regenerate types: `supabase gen types typescript --project-id <ref> > types/database.ts`. If you cannot run the CLI, hand-edit `types/database.ts` to match and note it in the PR.
|
||||
5. Update `DB_SCHEMA_MAP.md` if column names, defaults, or enum values changed.
|
||||
|
||||
## Naming
|
||||
|
||||
- Enums: singular, snake_case (`task_status`, not `task_statuses`).
|
||||
- Tables: plural, snake_case (`tasks`, `space_members`).
|
||||
- Foreign keys: `<other_table_singular>_id` (`space_id`, `member_id`).
|
||||
- Timestamps: `created_at`, `updated_at`, `<action>_at` (`claimed_at`, `completed_at`).
|
||||
- Policies: `<table>_<verb>` where verb is `select | insert | update | delete` (e.g. `members_insert`).
|
||||
|
||||
## Idempotency rules
|
||||
|
||||
- `CREATE TABLE IF NOT EXISTS` for every table.
|
||||
- `CREATE INDEX IF NOT EXISTS` for every index.
|
||||
- `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object THEN NULL; END $$` for every enum.
|
||||
- `DROP POLICY IF EXISTS ...` immediately before `CREATE POLICY ...`.
|
||||
- `CREATE OR REPLACE FUNCTION` for every function.
|
||||
- `DROP TRIGGER IF EXISTS ...` before `CREATE TRIGGER ...`.
|
||||
- `DO $$ BEGIN ALTER PUBLICATION ... ADD TABLE ...; EXCEPTION WHEN duplicate_object THEN NULL; END $$` for realtime membership.
|
||||
|
||||
If you find yourself reaching for a non-idempotent statement, wrap it.
|
||||
|
||||
## Security baseline
|
||||
|
||||
Every new table must, before merging:
|
||||
|
||||
- Have `ENABLE ROW LEVEL SECURITY`.
|
||||
- Have at least a SELECT policy and INSERT policy.
|
||||
- Scope every policy by `space_id IN (SELECT public.get_user_space_ids(auth.uid()))` or a role check via `public.user_has_role_in_space(...)`.
|
||||
- Be covered in `docs/DATABASE_SCHEMA.md` and `DB_SCHEMA_MAP.md`.
|
||||
|
||||
## Verifying a fresh deploy
|
||||
|
||||
```sql
|
||||
-- After running schema.sql, sanity-check:
|
||||
SELECT count(*) FROM pg_tables WHERE schemaname = 'public';
|
||||
SELECT count(*) FROM pg_policies WHERE schemaname = 'public';
|
||||
SELECT count(*) FROM pg_trigger WHERE tgrelid IN (
|
||||
SELECT oid FROM pg_class WHERE relnamespace = 'public'::regnamespace
|
||||
);
|
||||
SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
|
||||
```
|
||||
|
||||
You should see roughly: 13 tables, around 45 policies, multiple triggers, and the two `comms_*` tables in `supabase_realtime`.
|
||||
@@ -1,23 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const filePath = '/vercel/share/v0-project/lib/actions.ts'
|
||||
console.log('Patching:', filePath)
|
||||
let content = fs.readFileSync(filePath, 'utf8')
|
||||
|
||||
// Replace ALL .eq('user_id', user.id).eq('status', 'current').single() patterns
|
||||
content = content.replaceAll(
|
||||
`.eq('user_id', user.id).eq('status', 'current').single()`,
|
||||
`.eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()`
|
||||
)
|
||||
|
||||
// Also the multi-line variant
|
||||
content = content.replaceAll(
|
||||
`.eq('user_id', user.id)\n .eq('status', 'current')\n .single()`,
|
||||
`.eq('user_id', user.id)\n .in('status', ['current', 'unverified', 'late'])\n .single()`
|
||||
)
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf8')
|
||||
console.log('Done. File length:', content.length)
|
||||
const remaining = (content.match(/eq\('status', 'current'\)/g) || []).length
|
||||
console.log('Remaining eq(status,current) occurrences:', remaining)
|
||||
@@ -1,258 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Find the actual project root by looking for package.json
|
||||
const candidates = [
|
||||
'/vercel/share/v0-project',
|
||||
process.cwd(),
|
||||
]
|
||||
|
||||
let root = null
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(path.join(c, 'package.json'))) {
|
||||
root = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
// Walk up from cwd
|
||||
let dir = process.cwd()
|
||||
while (dir !== '/') {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) { root = dir; break }
|
||||
dir = path.dirname(dir)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Project root:', root)
|
||||
const target = path.join(root, 'components', 'app-sidebar.tsx')
|
||||
console.log('Target file:', target)
|
||||
console.log('Exists:', fs.existsSync(target))
|
||||
|
||||
if (!fs.existsSync(target)) {
|
||||
// Try to find it
|
||||
function findFile(dir, name) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue
|
||||
const full = path.join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
const found = findFile(full, name)
|
||||
if (found) return found
|
||||
} else if (e.name === name) return full
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
const found = findFile(root || '/', 'app-sidebar.tsx')
|
||||
console.log('Found at:', found)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let src = fs.readFileSync(target, 'utf8')
|
||||
|
||||
// Fix 1: add useEffect to import
|
||||
src = src.replace(
|
||||
`import { useState } from 'react'`,
|
||||
`import { useState, useEffect } from 'react'`
|
||||
)
|
||||
|
||||
// Fix 2: add useEffect after useState call
|
||||
src = src.replace(
|
||||
`const [drawerOpen, setDrawerOpen] = useState(false)`,
|
||||
`const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close drawer on route change — no onClick needed on nav links
|
||||
useEffect(() => { setDrawerOpen(false) }, [pathname])`
|
||||
)
|
||||
|
||||
// Fix 3: remove onClick from NavLink interface
|
||||
src = src.replace(` onClick?: () => void\n`, '')
|
||||
|
||||
// Fix 4: remove onClick from NavLink function params
|
||||
src = src.replace(
|
||||
`function NavLink({ href, label, icon: Icon, badge, active, onClick }: NavLinkProps) {`,
|
||||
`function NavLink({ href, label, icon: Icon, badge, active }: NavLinkProps) {`
|
||||
)
|
||||
|
||||
// Fix 5: remove onClick={onClick} from <Link>
|
||||
src = src.replace(` onClick={onClick}\n`, '')
|
||||
|
||||
// Fix 6: remove onClick from all NavLink usages
|
||||
src = src.replace(/ onClick=\{onNav\}/g, '')
|
||||
|
||||
fs.writeFileSync(target, src, 'utf8')
|
||||
console.log('Done! Wrote', src.length, 'bytes to', target)
|
||||
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { signOut } from '@/lib/auth-actions'
|
||||
import type { SpaceMember, Space } from '@/lib/types'
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
} from 'lucide-react'
|
||||
|
||||
function NavLink({
|
||||
href, label, icon: Icon, badge, active,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
badge?: number
|
||||
active: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className={cn('w-4 h-4', active ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')} />
|
||||
{label}
|
||||
</span>
|
||||
{badge && badge > 0 ? (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
member: SpaceMember & { spaces: Space }
|
||||
taskBadge?: number
|
||||
commsBadge?: number
|
||||
paymentBadge?: number
|
||||
}
|
||||
|
||||
export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const space = member.spaces
|
||||
const initials = member.display_name?.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2) || '??'
|
||||
const isAdmin = member.role === 'admin' || member.role === 'board'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close the drawer whenever the route changes
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
function isActive(href) {
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
}
|
||||
|
||||
const navJsx = (
|
||||
<>
|
||||
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-xs font-bold">{'{'}hs{'}'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
<div className="px-3 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
|
||||
</div>
|
||||
<NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} />
|
||||
<NavLink href="/tasks" label="Tasks and Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} />
|
||||
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} />
|
||||
<NavLink href="/ops" label="Ops and Facilities" icon={Settings2} active={isActive('/ops')} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
|
||||
</div>
|
||||
<NavLink href="/import" label="Import and Sync" icon={Download} active={isActive('/import')} />
|
||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--sidebar-border)] p-3">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full flex items-center gap-3 px-2 py-2 rounded-md hover:bg-[var(--sidebar-accent)] transition-colors group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)]/80 flex items-center justify-center text-white text-xs font-bold shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-[var(--sidebar-foreground)] text-xs font-medium truncate">{member.display_name}</p>
|
||||
<p className="text-[var(--sidebar-foreground)]/40 text-[10px] font-mono">{member.role} and {member.tier}</p>
|
||||
</div>
|
||||
<LogOut className="w-3.5 h-3.5 text-[var(--sidebar-foreground)]/30" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden md:flex w-[230px] shrink-0 bg-[var(--sidebar)] flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
|
||||
{navJsx}
|
||||
</aside>
|
||||
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 h-[52px] bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-[10px] font-bold">hs</span>
|
||||
</div>
|
||||
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-1"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drawerOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} />
|
||||
<div className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--sidebar-border)]">
|
||||
<span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">{space?.name}</span>
|
||||
<button onClick={() => setDrawerOpen(false)} className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navJsx}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync('/vercel/share/v0-project/components/app-sidebar.tsx', content, 'utf8')
|
||||
console.log('Done. Wrote', content.length, 'bytes')
|
||||
@@ -1,180 +0,0 @@
|
||||
import fs from 'fs'
|
||||
|
||||
const content = `'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { signOut } from '@/lib/auth-actions'
|
||||
import type { SpaceMember, Space } from '@/lib/types'
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
} from 'lucide-react'
|
||||
|
||||
function NavLink({
|
||||
href, label, icon: Icon, badge, active,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
badge?: number
|
||||
active: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className={cn('w-4 h-4', active ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')} />
|
||||
{label}
|
||||
</span>
|
||||
{badge && badge > 0 ? (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
member: SpaceMember & { spaces: Space }
|
||||
taskBadge?: number
|
||||
commsBadge?: number
|
||||
paymentBadge?: number
|
||||
}
|
||||
|
||||
export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const space = member.spaces
|
||||
const initials = member.display_name?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || '??'
|
||||
const isAdmin = member.role === 'admin' || member.role === 'board'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close the drawer whenever the route changes
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
function isActive(href: string) {
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
}
|
||||
|
||||
// navJsx is JSX stored in a variable — NOT a nested component.
|
||||
// This avoids React remounting it on every parent render, which was
|
||||
// causing the drawer to unmount before Next.js <Link> could navigate.
|
||||
const navJsx = (
|
||||
<>
|
||||
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-xs font-bold">{'{'}<span>hs</span>{'}'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
<div className="px-3 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
|
||||
</div>
|
||||
<NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} />
|
||||
<NavLink href="/tasks" label="Tasks & Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} />
|
||||
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} />
|
||||
<NavLink href="/ops" label="Ops & Facilities" icon={Settings2} active={isActive('/ops')} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
|
||||
</div>
|
||||
<NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} />
|
||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--sidebar-border)] p-3">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full flex items-center gap-3 px-2 py-2 rounded-md hover:bg-[var(--sidebar-accent)] transition-colors group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)]/80 flex items-center justify-center text-white text-xs font-bold shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-[var(--sidebar-foreground)] text-xs font-medium truncate">{member.display_name}</p>
|
||||
<p className="text-[var(--sidebar-foreground)]/40 text-[10px] font-mono">{member.role} · {member.tier}</p>
|
||||
</div>
|
||||
<LogOut className="w-3.5 h-3.5 text-[var(--sidebar-foreground)]/30" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:flex w-[230px] shrink-0 bg-[var(--sidebar)] flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
|
||||
{navJsx}
|
||||
</aside>
|
||||
|
||||
{/* Mobile: fixed top bar */}
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 h-[52px] bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-[10px] font-bold">hs</span>
|
||||
</div>
|
||||
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-1"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile: slide-out drawer */}
|
||||
{drawerOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} aria-hidden="true" />
|
||||
<div className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--sidebar-border)]">
|
||||
<span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">{space?.name}</span>
|
||||
<button onClick={() => setDrawerOpen(false)} className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)]" aria-label="Close menu">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navJsx}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync('/vercel/share/v0-project/components/app-sidebar.tsx', content, 'utf8')
|
||||
console.log('Wrote app-sidebar.tsx successfully, length:', content.length)
|
||||
@@ -1,35 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
|
||||
const path = '/vercel/share/v0-project/app/(app)/dashboard/page.tsx'
|
||||
let content = readFileSync(path, 'utf8')
|
||||
|
||||
// 1. Fix the payments query
|
||||
content = content.replace(
|
||||
`.is('linked_member_id', null)`,
|
||||
`.eq('link_status', 'unlinked')`
|
||||
)
|
||||
|
||||
// 2. Add ClaimButton import after the redirect import
|
||||
if (!content.includes('ClaimButton')) {
|
||||
content = content.replace(
|
||||
"import { redirect } from 'next/navigation'",
|
||||
"import { redirect } from 'next/navigation'\nimport { ClaimButton } from '@/components/task-claim-button'"
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Replace the static CLAIM link with ClaimButton
|
||||
content = content.replace(
|
||||
/<Link href="\/tasks" className="font-mono text-\[10px\] border border-border px-2 py-0\.5 rounded hover:border-primary hover:text-primary transition">CLAIM<\/Link>/,
|
||||
'{task.status === \'open\' && <ClaimButton taskId={task.id} />}'
|
||||
)
|
||||
|
||||
// 4. Fix project.name -> project.title
|
||||
content = content.replace(/project\.name\b/g, 'project.title')
|
||||
|
||||
writeFileSync(path, content, 'utf8')
|
||||
console.log('[v0] Dashboard patched successfully')
|
||||
console.log('[v0] Content preview (lines 1-10):')
|
||||
content.split('\n').slice(0, 10).forEach((line, i) => console.log(`${i+1}: ${line}`))
|
||||
console.log('[v0] Check linked_member_id still present:', content.includes('linked_member_id'))
|
||||
console.log('[v0] Check link_status present:', content.includes('link_status'))
|
||||
console.log('[v0] Check ClaimButton import:', content.includes('ClaimButton'))
|
||||
+717
-3
@@ -64,10 +64,13 @@ CREATE TABLE IF NOT EXISTS public.spaces (
|
||||
);
|
||||
|
||||
-- space_members ---------------------------------------------------------------
|
||||
-- user_id is NULLABLE on purpose. The application supports "offline" members
|
||||
-- (added by an admin without an auth account). addMember() and importMembers()
|
||||
-- both insert rows with user_id = NULL.
|
||||
CREATE TABLE IF NOT EXISTS public.space_members (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role public.member_role NOT NULL DEFAULT 'member',
|
||||
tier public.member_tier NOT NULL DEFAULT 'basic',
|
||||
status public.member_status NOT NULL DEFAULT 'unverified',
|
||||
@@ -384,7 +387,58 @@ $$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. ROW LEVEL SECURITY
|
||||
-- Drops first so re-runs do not fail with "policy already exists".
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS spaces_select ON public.spaces;
|
||||
DROP POLICY IF EXISTS spaces_insert ON public.spaces;
|
||||
DROP POLICY IF EXISTS spaces_update ON public.spaces;
|
||||
DROP POLICY IF EXISTS members_select ON public.space_members;
|
||||
DROP POLICY IF EXISTS members_insert ON public.space_members;
|
||||
DROP POLICY IF EXISTS members_update ON public.space_members;
|
||||
DROP POLICY IF EXISTS members_delete ON public.space_members;
|
||||
DROP POLICY IF EXISTS tasks_select ON public.tasks;
|
||||
DROP POLICY IF EXISTS tasks_insert ON public.tasks;
|
||||
DROP POLICY IF EXISTS tasks_update ON public.tasks;
|
||||
DROP POLICY IF EXISTS tasks_delete ON public.tasks;
|
||||
DROP POLICY IF EXISTS projects_select ON public.projects;
|
||||
DROP POLICY IF EXISTS projects_insert ON public.projects;
|
||||
DROP POLICY IF EXISTS projects_update ON public.projects;
|
||||
DROP POLICY IF EXISTS projects_delete ON public.projects;
|
||||
DROP POLICY IF EXISTS payments_select ON public.payments;
|
||||
DROP POLICY IF EXISTS payments_insert ON public.payments;
|
||||
DROP POLICY IF EXISTS payments_update ON public.payments;
|
||||
DROP POLICY IF EXISTS payments_delete ON public.payments;
|
||||
DROP POLICY IF EXISTS contacts_select ON public.contacts;
|
||||
DROP POLICY IF EXISTS contacts_insert ON public.contacts;
|
||||
DROP POLICY IF EXISTS contacts_update ON public.contacts;
|
||||
DROP POLICY IF EXISTS contacts_delete ON public.contacts;
|
||||
DROP POLICY IF EXISTS kb_select ON public.knowledge_base;
|
||||
DROP POLICY IF EXISTS kb_insert ON public.knowledge_base;
|
||||
DROP POLICY IF EXISTS kb_update ON public.knowledge_base;
|
||||
DROP POLICY IF EXISTS kb_delete ON public.knowledge_base;
|
||||
DROP POLICY IF EXISTS secrets_select ON public.secrets;
|
||||
DROP POLICY IF EXISTS secrets_insert ON public.secrets;
|
||||
DROP POLICY IF EXISTS secrets_update ON public.secrets;
|
||||
DROP POLICY IF EXISTS secrets_delete ON public.secrets;
|
||||
DROP POLICY IF EXISTS area_leads_select ON public.area_leads;
|
||||
DROP POLICY IF EXISTS area_leads_insert ON public.area_leads;
|
||||
DROP POLICY IF EXISTS area_leads_update ON public.area_leads;
|
||||
DROP POLICY IF EXISTS area_leads_delete ON public.area_leads;
|
||||
DROP POLICY IF EXISTS integrations_select ON public.integrations;
|
||||
DROP POLICY IF EXISTS integrations_insert ON public.integrations;
|
||||
DROP POLICY IF EXISTS integrations_update ON public.integrations;
|
||||
DROP POLICY IF EXISTS integrations_delete ON public.integrations;
|
||||
DROP POLICY IF EXISTS channels_select ON public.comms_channels;
|
||||
DROP POLICY IF EXISTS channels_insert ON public.comms_channels;
|
||||
DROP POLICY IF EXISTS channels_update ON public.comms_channels;
|
||||
DROP POLICY IF EXISTS channels_delete ON public.comms_channels;
|
||||
DROP POLICY IF EXISTS messages_select ON public.comms_messages;
|
||||
DROP POLICY IF EXISTS messages_insert ON public.comms_messages;
|
||||
DROP POLICY IF EXISTS messages_update ON public.comms_messages;
|
||||
DROP POLICY IF EXISTS messages_delete ON public.comms_messages;
|
||||
DROP POLICY IF EXISTS activity_select ON public.activity_log;
|
||||
DROP POLICY IF EXISTS activity_insert ON public.activity_log;
|
||||
|
||||
ALTER TABLE public.spaces ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.space_members ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;
|
||||
@@ -620,9 +674,669 @@ CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.handle_space_signup();
|
||||
|
||||
-- Privilege-escalation guard.
|
||||
-- RLS lets a member update their own space_members row (so they can fix their
|
||||
-- own display_name, handle, phone, etc.). Without this trigger, a member
|
||||
-- could PATCH role = 'admin' on themselves via a direct PostgREST call.
|
||||
-- The trigger fires on every UPDATE: if the user updating their own row is
|
||||
-- not already an admin/board/treasurer, any change to role/tier/status/
|
||||
-- approved is rejected. SECURITY DEFINER so it can read space_members
|
||||
-- regardless of RLS during the check.
|
||||
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_is_privileged boolean;
|
||||
BEGIN
|
||||
-- Service role (no auth.uid) and inserts handled elsewhere: skip.
|
||||
IF auth.uid() IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- If the row being updated does not belong to the current user, the
|
||||
-- existing RLS already requires admin/board/treasurer. Nothing to add.
|
||||
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Self-update. If the user already holds a privileged role IN THIS SPACE
|
||||
-- they may update anything (an admin editing their own row is fine).
|
||||
SELECT public.user_has_role_in_space(auth.uid(), NEW.space_id, ARRAY['admin','board','treasurer'])
|
||||
INTO v_is_privileged;
|
||||
|
||||
IF v_is_privileged THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Non-privileged self-update: protected columns must not change.
|
||||
IF NEW.role IS DISTINCT FROM OLD.role
|
||||
OR NEW.tier IS DISTINCT FROM OLD.tier
|
||||
OR NEW.status IS DISTINCT FROM OLD.status
|
||||
OR NEW.approved IS DISTINCT FROM OLD.approved
|
||||
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
|
||||
OR NEW.space_id IS DISTINCT FROM OLD.space_id THEN
|
||||
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, or space.'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_members;
|
||||
CREATE TRIGGER trg_prevent_member_self_role_change
|
||||
BEFORE UPDATE ON public.space_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change();
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 8. REALTIME
|
||||
-- Wrapped in DO blocks so re-running this file is safe: ADD TABLE errors when
|
||||
-- the table is already a member of the publication.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;
|
||||
DO $$ BEGIN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 9. GOVERNANCE KERNEL
|
||||
-- Source of truth: scripts/016_governance_kernel.sql.
|
||||
-- Tier 1 from docs/GOVERNANCE_FEATURES.md.
|
||||
-- Adds proposals + proposal_votes, incidents + incident_updates, policies.
|
||||
-- =============================================================================
|
||||
|
||||
-- 9.1 Enums ------------------------------------------------------------------
|
||||
DO $$ BEGIN CREATE TYPE public.proposal_type AS ENUM ('bylaw_change','board_action','membership_vote','advisory_poll','recall','budget'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.proposal_status AS ENUM ('draft','open','decided','withdrawn','expired'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.threshold_rule AS ENUM ('simple_majority','two_thirds','three_fourths','unanimous'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.vote_position AS ENUM ('yes','no','abstain','recused'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_status AS ENUM ('received','under_review','decided','appealed','closed'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_severity AS ENUM ('low','medium','high','critical'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_update_visibility AS ENUM ('reporter_only','all_parties','board_only'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.policy_status AS ENUM ('draft','active','deprecated','superseded'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- 9.2 Spaces extensions ------------------------------------------------------
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_percent integer NOT NULL DEFAULT 10;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_floor integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_voting_window_hours integer NOT NULL DEFAULT 216;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority';
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS incident_sla_hours integer NOT NULL DEFAULT 72;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS mission_statement text;
|
||||
|
||||
-- 9.3 Tables -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.proposals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
proposer_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
proposer_name text,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
proposal_type public.proposal_type NOT NULL DEFAULT 'advisory_poll',
|
||||
status public.proposal_status NOT NULL DEFAULT 'draft',
|
||||
quorum_required integer NOT NULL DEFAULT 0,
|
||||
quorum_percent integer NOT NULL DEFAULT 10,
|
||||
quorum_floor integer NOT NULL DEFAULT 1,
|
||||
threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority',
|
||||
voting_opens_at timestamptz,
|
||||
voting_closes_at timestamptz,
|
||||
policy_ref_id uuid,
|
||||
parent_incident_id uuid,
|
||||
outcome_yes integer NOT NULL DEFAULT 0,
|
||||
outcome_no integer NOT NULL DEFAULT 0,
|
||||
outcome_abstain integer NOT NULL DEFAULT 0,
|
||||
outcome_recused integer NOT NULL DEFAULT 0,
|
||||
total_voters integer NOT NULL DEFAULT 0,
|
||||
quorum_met boolean,
|
||||
passed boolean,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.proposal_votes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
proposal_id uuid NOT NULL REFERENCES public.proposals(id) ON DELETE CASCADE,
|
||||
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
|
||||
position public.vote_position NOT NULL,
|
||||
recusal_reason text,
|
||||
comment text,
|
||||
voted_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (proposal_id, member_id),
|
||||
CONSTRAINT recused_requires_reason
|
||||
CHECK (position <> 'recused' OR (recusal_reason IS NOT NULL AND length(trim(recusal_reason)) > 0))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.incidents (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
reporter_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
reporter_token text UNIQUE,
|
||||
is_anonymous boolean NOT NULL DEFAULT false,
|
||||
subjects uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
category text NOT NULL DEFAULT 'general',
|
||||
severity public.incident_severity NOT NULL DEFAULT 'medium',
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
status public.incident_status NOT NULL DEFAULT 'received',
|
||||
disposition text,
|
||||
decision_maker_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
appeal_proposal_id uuid,
|
||||
sla_response_by timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
acknowledged_at timestamptz,
|
||||
decided_at timestamptz,
|
||||
closed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.incident_updates (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
incident_id uuid NOT NULL REFERENCES public.incidents(id) ON DELETE CASCADE,
|
||||
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
author_name text,
|
||||
body text NOT NULL,
|
||||
visibility public.incident_update_visibility NOT NULL DEFAULT 'all_parties',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.policies (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL,
|
||||
section_ref text,
|
||||
parent_policy_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
body_formal text NOT NULL DEFAULT '',
|
||||
body_plain text,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
prior_version_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
||||
status public.policy_status NOT NULL DEFAULT 'draft',
|
||||
effective_at timestamptz,
|
||||
adopted_by_proposal_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, slug, version)
|
||||
);
|
||||
|
||||
-- 9.4 Circular FKs -----------------------------------------------------------
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.proposals ADD CONSTRAINT proposals_policy_ref_fk
|
||||
FOREIGN KEY (policy_ref_id) REFERENCES public.policies(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.proposals ADD CONSTRAINT proposals_parent_incident_fk
|
||||
FOREIGN KEY (parent_incident_id) REFERENCES public.incidents(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.incidents ADD CONSTRAINT incidents_appeal_proposal_fk
|
||||
FOREIGN KEY (appeal_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.policies ADD CONSTRAINT policies_adopted_by_proposal_fk
|
||||
FOREIGN KEY (adopted_by_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- 9.5 Indexes ----------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_space ON public.proposals (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_status ON public.proposals (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_proposer ON public.proposals (proposer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_closes ON public.proposals (voting_closes_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_proposal ON public.proposal_votes (proposal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_member ON public.proposal_votes (member_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_space ON public.incidents (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_reporter ON public.incidents (reporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_status ON public.incidents (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_subjects ON public.incidents USING GIN (subjects);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_incidents_token_unique
|
||||
ON public.incidents (reporter_token) WHERE reporter_token IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON public.incident_updates (incident_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_space_slug ON public.policies (space_id, slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_status ON public.policies (status);
|
||||
|
||||
-- 9.6 Triggers ---------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.compute_proposal_quorum()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_member_count integer;
|
||||
v_quorum_percent integer;
|
||||
v_quorum_floor integer;
|
||||
v_voting_window_hours integer;
|
||||
BEGIN
|
||||
IF NEW.status = 'open' AND (TG_OP = 'INSERT' OR OLD.status = 'draft') THEN
|
||||
SELECT default_quorum_percent, default_quorum_floor, default_voting_window_hours
|
||||
INTO v_quorum_percent, v_quorum_floor, v_voting_window_hours
|
||||
FROM public.spaces WHERE id = NEW.space_id;
|
||||
SELECT count(*) INTO v_member_count
|
||||
FROM public.space_members
|
||||
WHERE space_id = NEW.space_id AND status IN ('current','late') AND approved = true;
|
||||
NEW.quorum_percent := COALESCE(v_quorum_percent, 10);
|
||||
NEW.quorum_floor := COALESCE(v_quorum_floor, 1);
|
||||
NEW.quorum_required := GREATEST(NEW.quorum_floor, CEIL(v_member_count * NEW.quorum_percent / 100.0)::integer);
|
||||
IF NEW.voting_opens_at IS NULL THEN NEW.voting_opens_at := now(); END IF;
|
||||
IF NEW.voting_closes_at IS NULL THEN
|
||||
NEW.voting_closes_at := NEW.voting_opens_at + (v_voting_window_hours || ' hours')::interval;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_compute_proposal_quorum ON public.proposals;
|
||||
CREATE TRIGGER trg_compute_proposal_quorum
|
||||
BEFORE INSERT OR UPDATE ON public.proposals
|
||||
FOR EACH ROW EXECUTE FUNCTION public.compute_proposal_quorum();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.refresh_proposal_tally()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_proposal_id uuid; v_yes integer; v_no integer; v_abstain integer; v_recused integer; v_total integer;
|
||||
v_quorum_required integer; v_threshold public.threshold_rule; v_status public.proposal_status;
|
||||
v_quorum_met boolean; v_passed boolean;
|
||||
BEGIN
|
||||
v_proposal_id := COALESCE(NEW.proposal_id, OLD.proposal_id);
|
||||
SELECT count(*) FILTER (WHERE position='yes'), count(*) FILTER (WHERE position='no'),
|
||||
count(*) FILTER (WHERE position='abstain'), count(*) FILTER (WHERE position='recused'), count(*)
|
||||
INTO v_yes, v_no, v_abstain, v_recused, v_total
|
||||
FROM public.proposal_votes WHERE proposal_id = v_proposal_id;
|
||||
SELECT quorum_required, threshold, status INTO v_quorum_required, v_threshold, v_status
|
||||
FROM public.proposals WHERE id = v_proposal_id;
|
||||
v_quorum_met := (v_yes + v_no + v_abstain) >= COALESCE(v_quorum_required, 0);
|
||||
v_passed := CASE
|
||||
WHEN NOT v_quorum_met THEN false
|
||||
WHEN v_yes + v_no = 0 THEN false
|
||||
WHEN v_threshold = 'simple_majority' THEN v_yes > v_no
|
||||
WHEN v_threshold = 'two_thirds' THEN v_yes * 3 >= (v_yes + v_no) * 2
|
||||
WHEN v_threshold = 'three_fourths' THEN v_yes * 4 >= (v_yes + v_no) * 3
|
||||
WHEN v_threshold = 'unanimous' THEN v_no = 0 AND v_yes > 0
|
||||
ELSE false END;
|
||||
UPDATE public.proposals
|
||||
SET outcome_yes=v_yes, outcome_no=v_no, outcome_abstain=v_abstain, outcome_recused=v_recused,
|
||||
total_voters=v_total, quorum_met=v_quorum_met, passed=v_passed, updated_at=now()
|
||||
WHERE id = v_proposal_id;
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_refresh_proposal_tally ON public.proposal_votes;
|
||||
CREATE TRIGGER trg_refresh_proposal_tally
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.proposal_votes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.refresh_proposal_tally();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.compute_incident_sla()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE v_hours integer;
|
||||
BEGIN
|
||||
IF NEW.sla_response_by IS NULL THEN
|
||||
SELECT incident_sla_hours INTO v_hours FROM public.spaces WHERE id = NEW.space_id;
|
||||
NEW.sla_response_by := COALESCE(NEW.created_at, now()) + (COALESCE(v_hours, 72) || ' hours')::interval;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_compute_incident_sla ON public.incidents;
|
||||
CREATE TRIGGER trg_compute_incident_sla
|
||||
BEFORE INSERT ON public.incidents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.compute_incident_sla();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.touch_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN NEW.updated_at := now(); RETURN NEW; END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_proposals_touch ON public.proposals;
|
||||
CREATE TRIGGER trg_proposals_touch BEFORE UPDATE ON public.proposals FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
DROP TRIGGER IF EXISTS trg_incidents_touch ON public.incidents;
|
||||
CREATE TRIGGER trg_incidents_touch BEFORE UPDATE ON public.incidents FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
DROP TRIGGER IF EXISTS trg_policies_touch ON public.policies;
|
||||
CREATE TRIGGER trg_policies_touch BEFORE UPDATE ON public.policies FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
-- 9.7 RLS --------------------------------------------------------------------
|
||||
ALTER TABLE public.proposals ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.proposal_votes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.incidents ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.incident_updates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.policies ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS proposals_select ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_insert ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_update ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_delete ON public.proposals;
|
||||
DROP POLICY IF EXISTS votes_select ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS votes_insert ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS votes_update ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS incidents_select ON public.incidents;
|
||||
DROP POLICY IF EXISTS incidents_insert ON public.incidents;
|
||||
DROP POLICY IF EXISTS incidents_update ON public.incidents;
|
||||
DROP POLICY IF EXISTS incident_updates_select ON public.incident_updates;
|
||||
DROP POLICY IF EXISTS incident_updates_insert ON public.incident_updates;
|
||||
DROP POLICY IF EXISTS policies_select ON public.policies;
|
||||
DROP POLICY IF EXISTS policies_insert ON public.policies;
|
||||
DROP POLICY IF EXISTS policies_update ON public.policies;
|
||||
|
||||
CREATE POLICY proposals_select ON public.proposals FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY proposals_insert ON public.proposals FOR INSERT
|
||||
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY proposals_update ON public.proposals FOR UPDATE
|
||||
USING (
|
||||
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
||||
OR (status = 'draft' AND proposer_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
|
||||
);
|
||||
CREATE POLICY proposals_delete ON public.proposals FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
|
||||
CREATE POLICY votes_select ON public.proposal_votes FOR SELECT
|
||||
USING (EXISTS (SELECT 1 FROM public.proposals p WHERE p.id = proposal_votes.proposal_id
|
||||
AND p.space_id IN (SELECT public.get_user_space_ids(auth.uid()))));
|
||||
CREATE POLICY votes_insert ON public.proposal_votes FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.proposals p
|
||||
JOIN public.space_members m ON m.id = proposal_votes.member_id
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = p.space_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_opens_at <= now()
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
CREATE POLICY votes_update ON public.proposal_votes FOR UPDATE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.proposals p
|
||||
JOIN public.space_members m ON m.id = proposal_votes.member_id
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = p.space_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY incidents_select ON public.incidents FOR SELECT
|
||||
USING (
|
||||
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
||||
OR reporter_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid())
|
||||
);
|
||||
CREATE POLICY incidents_insert ON public.incidents FOR INSERT
|
||||
WITH CHECK (
|
||||
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||
AND (
|
||||
reporter_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.space_members m
|
||||
WHERE m.id = incidents.reporter_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = incidents.space_id
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY incidents_update ON public.incidents FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
|
||||
CREATE POLICY incident_updates_select ON public.incident_updates FOR SELECT
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.incidents i WHERE i.id = incident_updates.incident_id
|
||||
AND (public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR (incident_updates.visibility <> 'board_only'
|
||||
AND i.reporter_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))))
|
||||
);
|
||||
CREATE POLICY incident_updates_insert ON public.incident_updates FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND (
|
||||
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR i.reporter_id IN (
|
||||
SELECT m.id FROM public.space_members m
|
||||
WHERE m.user_id = auth.uid() AND m.space_id = i.space_id
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
author_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.space_members m, public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND m.id = incident_updates.author_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = i.space_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY policies_select ON public.policies FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY policies_insert ON public.policies FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY policies_update ON public.policies FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
|
||||
-- 9.8 Active-policy uniqueness ------------------------------------------------
|
||||
-- A race between two admins concurrently activating two different drafts of
|
||||
-- the same slug could leave two `active` rows. Partial unique index forbids
|
||||
-- it at the storage layer.
|
||||
DROP INDEX IF EXISTS public.policies_one_active_per_slug;
|
||||
CREATE UNIQUE INDEX policies_one_active_per_slug
|
||||
ON public.policies (space_id, slug)
|
||||
WHERE status = 'active';
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 10. MEMBER STATE + VISIBILITY (Tier 2 + Tier 3 of GOVERNANCE_FEATURES.md)
|
||||
-- Source of truth: scripts/018_member_state_and_visibility.sql.
|
||||
-- =============================================================================
|
||||
|
||||
-- 10.1 Enums
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.financial_visibility AS ENUM ('treasurer_only','board_visible','all_members_visible');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.directory_visibility AS ENUM ('board_only','member_count_visible','members_visible','public_members_visible');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- 10.2 Spaces visibility settings
|
||||
ALTER TABLE public.spaces
|
||||
ADD COLUMN IF NOT EXISTS financial_visibility public.financial_visibility NOT NULL DEFAULT 'board_visible';
|
||||
ALTER TABLE public.spaces
|
||||
ADD COLUMN IF NOT EXISTS member_directory_visibility public.directory_visibility NOT NULL DEFAULT 'members_visible';
|
||||
|
||||
-- 10.3 Space-members opt-in profile and COI
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS skills text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS interests text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS willing_to text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS affiliations text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS coi_last_disclosed_at timestamptz;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_space_members_willing_to ON public.space_members USING GIN (willing_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_members_skills ON public.space_members USING GIN (skills);
|
||||
|
||||
-- 10.4 Knowledge-base meeting minutes flag
|
||||
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS is_meeting_minutes boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS meeting_date timestamptz;
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_meeting_date
|
||||
ON public.knowledge_base (meeting_date DESC)
|
||||
WHERE is_meeting_minutes = true;
|
||||
|
||||
-- 10.5 Card-access mid-tenure review trigger
|
||||
CREATE OR REPLACE FUNCTION public.schedule_card_review()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_review_date timestamptz;
|
||||
v_old_card boolean;
|
||||
BEGIN
|
||||
v_old_card := COALESCE(OLD.has_card_access, false);
|
||||
IF NEW.has_card_access = true AND (TG_OP = 'INSERT' OR v_old_card = false) THEN
|
||||
v_review_date := now() + interval '180 days';
|
||||
INSERT INTO public.tasks (
|
||||
space_id, title, description, task_type, status, area, due_date, requested_by_name
|
||||
) VALUES (
|
||||
NEW.space_id,
|
||||
'Card-access review: ' || COALESCE(NULLIF(NEW.display_name, ''), 'member'),
|
||||
'Auto-generated 6-month review. Confirm card-access should continue. Any concerns from area leads, station champions, or hosts?',
|
||||
'task',
|
||||
'open',
|
||||
'Admin',
|
||||
v_review_date,
|
||||
'System'
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_schedule_card_review ON public.space_members;
|
||||
CREATE TRIGGER trg_schedule_card_review
|
||||
AFTER INSERT OR UPDATE OF has_card_access ON public.space_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.schedule_card_review();
|
||||
|
||||
-- 10.6 Inactive members view
|
||||
CREATE OR REPLACE VIEW public.inactive_members AS
|
||||
SELECT m.*,
|
||||
a.last_activity_at
|
||||
FROM public.space_members m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(created_at) AS last_activity_at
|
||||
FROM public.activity_log al
|
||||
WHERE al.user_id = m.user_id AND al.space_id = m.space_id
|
||||
) a ON true
|
||||
WHERE m.status IN ('current', 'late')
|
||||
AND m.approved = true
|
||||
AND (a.last_activity_at IS NULL OR a.last_activity_at < now() - interval '180 days');
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 11. PROPOSAL EXPIRY
|
||||
-- Source: scripts/019_proposal_expiry.sql.
|
||||
-- Call public.expire_proposals() on a schedule to auto-close open proposals
|
||||
-- whose voting_closes_at has passed.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.expire_proposals()
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_decided integer;
|
||||
v_expired integer;
|
||||
BEGIN
|
||||
WITH updated AS (
|
||||
UPDATE public.proposals
|
||||
SET status = 'decided', decided_at = now()
|
||||
WHERE status = 'open'
|
||||
AND voting_closes_at IS NOT NULL
|
||||
AND voting_closes_at < now()
|
||||
AND quorum_met = true
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) INTO v_decided FROM updated;
|
||||
|
||||
WITH updated AS (
|
||||
UPDATE public.proposals
|
||||
SET status = 'expired'
|
||||
WHERE status = 'open'
|
||||
AND voting_closes_at IS NOT NULL
|
||||
AND voting_closes_at < now()
|
||||
AND COALESCE(quorum_met, false) = false
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) INTO v_expired FROM updated;
|
||||
|
||||
RETURN v_decided + v_expired;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN
|
||||
PERFORM cron.unschedule(jobid) FROM cron.job WHERE jobname = 'expire-proposals-hourly';
|
||||
PERFORM cron.schedule('expire-proposals-hourly', '0 * * * *', $cron$SELECT public.expire_proposals();$cron$);
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
NULL;
|
||||
END $$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 12. SPACE AREAS
|
||||
-- Source: scripts/020_areas.sql.
|
||||
-- Each space gets its own list of physical areas / shops / categories.
|
||||
-- A trigger seeds ten sensible defaults when a space is created. Admin and
|
||||
-- board members manage the list afterwards.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.space_areas (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
code text NOT NULL,
|
||||
name text NOT NULL,
|
||||
icon text,
|
||||
sort_order integer NOT NULL DEFAULT 100,
|
||||
is_archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, code),
|
||||
UNIQUE (space_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_space_areas_space_sort
|
||||
ON public.space_areas (space_id, sort_order, name);
|
||||
|
||||
ALTER TABLE public.space_areas ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS space_areas_select ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_insert ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_update ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_delete ON public.space_areas;
|
||||
|
||||
CREATE POLICY space_areas_select ON public.space_areas FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY space_areas_insert ON public.space_areas FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY space_areas_update ON public.space_areas FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY space_areas_delete ON public.space_areas FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_space_areas_touch ON public.space_areas;
|
||||
CREATE TRIGGER trg_space_areas_touch
|
||||
BEFORE UPDATE ON public.space_areas
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_areas()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.space_areas (space_id, code, name, sort_order) VALUES
|
||||
(NEW.id, '3d-printing', '3D Printing', 10),
|
||||
(NEW.id, 'electronics', 'Electronics', 20),
|
||||
(NEW.id, 'woodshop', 'Woodshop', 30),
|
||||
(NEW.id, 'laser', 'Laser', 40),
|
||||
(NEW.id, 'metal-shop', 'Metal Shop', 50),
|
||||
(NEW.id, 'facilities', 'Facilities', 60),
|
||||
(NEW.id, 'software', 'Software', 70),
|
||||
(NEW.id, 'kitchen', 'Kitchen', 80),
|
||||
(NEW.id, 'admin', 'Admin', 90),
|
||||
(NEW.id, 'general', 'General', 100)
|
||||
ON CONFLICT (space_id, code) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
-- =============================================================================
|
||||
-- Seed data for local development
|
||||
-- =============================================================================
|
||||
-- Loads a demo space populated with offline members (no auth.users rows),
|
||||
-- tasks, a project, a knowledge-base entry, a policy, three proposals in
|
||||
-- different states, and an incident. Activity log entries reflect the work.
|
||||
--
|
||||
-- Run AFTER schema.sql + migrations:
|
||||
-- psql "$DATABASE_URL" -f scripts/seed.sql
|
||||
-- Or via Supabase CLI:
|
||||
-- supabase db reset && psql "$DATABASE_URL" -f scripts/seed.sql
|
||||
--
|
||||
-- Idempotent: ON CONFLICT DO NOTHING on the seed UUIDs.
|
||||
-- Wipes itself first by deleting rows scoped to the seed space_id.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_space_id uuid := '11111111-1111-1111-1111-111111111111';
|
||||
v_alice_id uuid := '22222222-2222-2222-2222-222222222221'; -- admin
|
||||
v_bob_id uuid := '22222222-2222-2222-2222-222222222222'; -- board
|
||||
v_carla_id uuid := '22222222-2222-2222-2222-222222222223'; -- treasurer
|
||||
v_dan_id uuid := '22222222-2222-2222-2222-222222222224'; -- member
|
||||
v_eve_id uuid := '22222222-2222-2222-2222-222222222225'; -- associate
|
||||
v_policy_id uuid := '33333333-3333-3333-3333-333333333331';
|
||||
v_proposal_open uuid := '44444444-4444-4444-4444-444444444441';
|
||||
v_proposal_draft uuid := '44444444-4444-4444-4444-444444444442';
|
||||
v_proposal_done uuid := '44444444-4444-4444-4444-444444444443';
|
||||
v_incident_id uuid := '55555555-5555-5555-5555-555555555551';
|
||||
v_task_open uuid := '66666666-6666-6666-6666-666666666661';
|
||||
v_task_chore uuid := '66666666-6666-6666-6666-666666666662';
|
||||
v_task_done uuid := '66666666-6666-6666-6666-666666666663';
|
||||
v_project_id uuid := '77777777-7777-7777-7777-777777777771';
|
||||
BEGIN
|
||||
-- Wipe prior seed (idempotent).
|
||||
DELETE FROM public.proposal_votes WHERE proposal_id IN (v_proposal_open, v_proposal_draft, v_proposal_done);
|
||||
DELETE FROM public.incident_updates WHERE incident_id = v_incident_id;
|
||||
DELETE FROM public.proposals WHERE space_id = v_space_id;
|
||||
DELETE FROM public.incidents WHERE space_id = v_space_id;
|
||||
DELETE FROM public.policies WHERE space_id = v_space_id;
|
||||
DELETE FROM public.tasks WHERE space_id = v_space_id;
|
||||
DELETE FROM public.projects WHERE space_id = v_space_id;
|
||||
DELETE FROM public.knowledge_base WHERE space_id = v_space_id;
|
||||
DELETE FROM public.activity_log WHERE space_id = v_space_id;
|
||||
DELETE FROM public.comms_channels WHERE space_id = v_space_id;
|
||||
DELETE FROM public.space_members WHERE space_id = v_space_id;
|
||||
DELETE FROM public.spaces WHERE id = v_space_id;
|
||||
|
||||
-- Space
|
||||
INSERT INTO public.spaces (
|
||||
id, name, slug, city, invite_code, require_approval, mission_statement,
|
||||
financial_visibility, member_directory_visibility,
|
||||
default_quorum_percent, default_quorum_floor, default_voting_window_hours,
|
||||
default_threshold, incident_sla_hours
|
||||
) VALUES (
|
||||
v_space_id, 'Demo Hackerspace', 'demo', 'Mesa, AZ', 'DEMO-2026-TEST', true,
|
||||
'A community-driven hackerspace committed to radical inclusivity, open tools, and shared stewardship.',
|
||||
'board_visible', 'members_visible',
|
||||
10, 2, 216, 'simple_majority', 72
|
||||
);
|
||||
|
||||
-- Members (all offline — no user_id; real signups via invite code attach later).
|
||||
INSERT INTO public.space_members (
|
||||
id, space_id, user_id, role, tier, status, approved,
|
||||
display_name, handle, email, phone, has_card_access,
|
||||
skills, interests, willing_to, affiliations, coi_last_disclosed_at
|
||||
) VALUES
|
||||
(v_alice_id, v_space_id, NULL, 'admin', 'plus', 'current', true,
|
||||
'Alice Admin', 'alice', '[email protected]', '+1 555 0101', true,
|
||||
ARRAY['electronics','laser','3d printing'], ARRAY['cnc','robotics'],
|
||||
ARRAY['board_candidate','docs_steward'],
|
||||
ARRAY['ACME Robotics, contractor'], now() - interval '60 days'),
|
||||
(v_bob_id, v_space_id, NULL, 'board', 'plus', 'current', true,
|
||||
'Bob Board', 'bob', '[email protected]', '+1 555 0102', true,
|
||||
ARRAY['welding','metal shop'], ARRAY['blacksmithing'],
|
||||
ARRAY['safety_committee','event_organizer'],
|
||||
ARRAY[]::text[], NULL),
|
||||
(v_carla_id, v_space_id, NULL, 'treasurer', 'plus', 'current', true,
|
||||
'Carla Treasurer', 'carla', '[email protected]', '+1 555 0103', false,
|
||||
ARRAY['accounting','grant writing'], ARRAY['nonprofit law'],
|
||||
ARRAY['treasurer_candidate'],
|
||||
ARRAY['Local Credit Union, board'], now() - interval '15 days'),
|
||||
(v_dan_id, v_space_id, NULL, 'member', 'basic', 'current', true,
|
||||
'Dan Maker', 'dan', '[email protected]', '+1 555 0104', true,
|
||||
ARRAY['woodshop','laser','arduino'], ARRAY['guitars','furniture'],
|
||||
ARRAY['host_volunteer','area_lead_candidate'],
|
||||
ARRAY[]::text[], NULL),
|
||||
(v_eve_id, v_space_id, NULL, 'associate', 'associate', 'current', true,
|
||||
'Eve Visitor', 'eve', '[email protected]', NULL, false,
|
||||
ARRAY['photography'], ARRAY[]::text[],
|
||||
ARRAY[]::text[],
|
||||
ARRAY[]::text[], NULL);
|
||||
|
||||
-- The spaces trigger already created default comms channels (general,
|
||||
-- announcements, ops). No additional channel inserts needed.
|
||||
|
||||
-- Policies (one active v1, with a clear plain-language summary).
|
||||
INSERT INTO public.policies (
|
||||
id, space_id, slug, section_ref, title,
|
||||
body_plain, body_formal,
|
||||
version, status, effective_at
|
||||
) VALUES (
|
||||
v_policy_id, v_space_id, 'code-of-conduct', '1', 'Code of Conduct',
|
||||
'Be excellent to each other. No harassment, no theft, no unsafe behavior. Disagreements are resolved through proposals or through a board incident report. If you see a problem, file an incident.',
|
||||
E'## Article 1. Purpose\n\nThe Demo Hackerspace is a community workshop for makers, hackers, artists, and learners of all backgrounds.\n\n## Article 2. Conduct\n\n1. **Respect.** Treat every member, guest, and visitor with respect.\n2. **Safety.** Follow station-specific safety guidance. Do not operate equipment you have not been signed off on.\n3. **Stewardship.** Clean up after yourself. Report broken or unsafe equipment.\n\n## Article 3. Enforcement\n\nViolations are reported via the **Incidents** workflow. The board acknowledges within 72 hours and decides within 14 days. Members may appeal a dismissal to a membership vote per Article 4.\n\n## Article 4. Appeal\n\nAny member whose incident report is dismissed by the board may petition the membership for a vote. The petition opens an **Appeal** proposal; passage requires simple majority of cast votes with quorum of 10% or 2 members, whichever is greater.',
|
||||
1, 'active', now() - interval '90 days'
|
||||
);
|
||||
|
||||
-- Tasks (a mix).
|
||||
INSERT INTO public.tasks (
|
||||
id, space_id, title, description, task_type, status, area, recurrence,
|
||||
requested_by_name, claimed_by_name, due_date, created_at
|
||||
) VALUES
|
||||
(v_task_open, v_space_id, 'Wipe down laser cutter bed',
|
||||
'Acetone + lint-free cloth. Check the honeycomb for stuck bits.',
|
||||
'chore', 'open', 'Laser', 'weekly',
|
||||
'Alice Admin', NULL, now() + interval '2 days', now() - interval '1 day'),
|
||||
(v_task_chore, v_space_id, 'Empty trash bins',
|
||||
'All three bins by the door. Recycling goes in the blue bin.',
|
||||
'chore', 'claimed', 'Facilities', 'weekly',
|
||||
'Bob Board', 'Dan Maker', now() + interval '12 hours', now() - interval '3 days'),
|
||||
(v_task_done, v_space_id, 'Reorder filament',
|
||||
'PETG, black. Hatchbox 1kg. Reimburse via Carla.',
|
||||
'task', 'completed', '3D Printing', 'none',
|
||||
'Dan Maker', NULL, NULL, now() - interval '7 days');
|
||||
|
||||
-- A project.
|
||||
INSERT INTO public.projects (
|
||||
id, space_id, title, description, area, status, tags, assignee_names
|
||||
) VALUES (
|
||||
v_project_id, v_space_id, 'CNC mount for the woodshop bench',
|
||||
'Build a removable plate so the small CNC can share the workbench with hand work.',
|
||||
'Woodshop', 'in_progress', ARRAY['fab','urgent'],
|
||||
ARRAY['Dan Maker','Bob Board']
|
||||
);
|
||||
|
||||
-- A knowledge-base entry.
|
||||
INSERT INTO public.knowledge_base (
|
||||
space_id, title, content, area, visibility, is_pinned,
|
||||
updated_by_name, tags
|
||||
) VALUES (
|
||||
v_space_id, 'Door system: the incantations',
|
||||
E'The door fob system runs on `mqtt://door.local`.\n\n1. Add a new fob: scan in the admin app, paste the serial into Members > Edit.\n2. Disable a fob: flip `has_card_access` to false on the member row.\n3. If the door is stuck, power-cycle the relay module behind the bookcase.\n\nIf the entire system is down, the manual override key is in the lockbox by the back door. Code is on the wiki under "Door". This is not a secret; it is the failover.',
|
||||
'Facilities', 'all_members', true,
|
||||
'Alice Admin', ARRAY['door','access','infra']
|
||||
);
|
||||
|
||||
-- An OPEN proposal (members can vote on it).
|
||||
INSERT INTO public.proposals (
|
||||
id, space_id, proposer_id, proposer_name, title, body,
|
||||
proposal_type, status, threshold,
|
||||
voting_opens_at, voting_closes_at
|
||||
) VALUES (
|
||||
v_proposal_open, v_space_id, v_dan_id, 'Dan Maker',
|
||||
'Adopt a quarterly equipment-budget review',
|
||||
E'## Why\n\nWe spend on equipment without a regular review. Last year we duplicated a $400 saw and let a $1200 laser tube go uncovered for two months.\n\n## What I propose\n\n- Every quarter, the treasurer publishes a one-page equipment-budget report to /financials.\n- The board votes to allocate the next quarter''s budget.\n- Members can submit equipment requests via a Tasks-style queue.\n\n## Cost\n\nZero. Procedural change only.',
|
||||
'board_action', 'open', 'simple_majority',
|
||||
now() - interval '3 days', now() + interval '6 days'
|
||||
);
|
||||
|
||||
-- A DRAFT proposal (not yet open for voting).
|
||||
INSERT INTO public.proposals (
|
||||
id, space_id, proposer_id, proposer_name, title, body,
|
||||
proposal_type, status, threshold,
|
||||
voting_opens_at, voting_closes_at
|
||||
) VALUES (
|
||||
v_proposal_draft, v_space_id, v_alice_id, 'Alice Admin',
|
||||
'Bylaw change: raise quorum floor to 5',
|
||||
'Currently quorum floor is 2. Proposing 5 to better reflect membership growth. This is a draft — please leave comments before I open voting.',
|
||||
'bylaw_change', 'draft', 'two_thirds',
|
||||
NULL, NULL
|
||||
);
|
||||
|
||||
-- A DECIDED proposal (showcasing the archive).
|
||||
INSERT INTO public.proposals (
|
||||
id, space_id, proposer_id, proposer_name, title, body,
|
||||
proposal_type, status, threshold,
|
||||
voting_opens_at, voting_closes_at, decided_at
|
||||
) VALUES (
|
||||
v_proposal_done, v_space_id, v_bob_id, 'Bob Board',
|
||||
'Approve June facility deep-clean budget of $400',
|
||||
E'## Scope\n\nProfessional clean of the woodshop, electronics area, and bathroom. Bid from "ACME Cleaning" is $400 flat.\n\n## Why\n\nWe have not had a professional clean in 11 months. Member-volunteer cleans cover surface only.',
|
||||
'budget', 'decided', 'simple_majority',
|
||||
now() - interval '20 days', now() - interval '11 days', now() - interval '10 days'
|
||||
);
|
||||
|
||||
-- Votes on the open and decided proposals.
|
||||
INSERT INTO public.proposal_votes (proposal_id, member_id, position, comment) VALUES
|
||||
(v_proposal_open, v_alice_id, 'yes', 'Strongly support.'),
|
||||
(v_proposal_open, v_bob_id, 'yes', 'Yes.'),
|
||||
(v_proposal_open, v_carla_id, 'abstain', 'Will share data once a quarter regardless.'),
|
||||
(v_proposal_done, v_alice_id, 'yes', NULL),
|
||||
(v_proposal_done, v_bob_id, 'yes', NULL),
|
||||
(v_proposal_done, v_carla_id, 'yes', 'Money is set aside.'),
|
||||
(v_proposal_done, v_dan_id, 'no', 'Prefer member volunteer day instead.');
|
||||
|
||||
-- An open incident.
|
||||
INSERT INTO public.incidents (
|
||||
id, space_id, reporter_id, is_anonymous,
|
||||
category, severity, title, body, status, subjects
|
||||
) VALUES (
|
||||
v_incident_id, v_space_id, v_dan_id, false,
|
||||
'safety', 'high', 'Saw left running unattended in the woodshop on Friday',
|
||||
E'On Friday around 10pm I came in to use the woodshop. The 14" bandsaw was running with nobody nearby. I shut it off and left a note on the whiteboard.\n\nNo damage done, but this is a safety policy violation and I want it on record.',
|
||||
'received', ARRAY[]::uuid[]
|
||||
);
|
||||
|
||||
-- Seed activity log so the dashboard has recent activity to show.
|
||||
INSERT INTO public.activity_log (space_id, user_id, display_name, action, entity_type, entity_id, details, created_at) VALUES
|
||||
(v_space_id, NULL, 'Alice Admin', 'created', 'policy', v_policy_id, 'Code of Conduct v1', now() - interval '90 days'),
|
||||
(v_space_id, NULL, 'Bob Board', 'logged', 'payment', NULL, '$120 cash dues', now() - interval '12 days'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'created', 'task', v_task_open, 'Wipe down laser cutter', now() - interval '1 day'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'claimed', 'task', v_task_chore, NULL, now() - interval '2 days'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'completed','task', v_task_done, NULL, now() - interval '6 days'),
|
||||
(v_space_id, NULL, 'Bob Board', 'opened', 'proposal', v_proposal_open, 'Quarterly budget review',now() - interval '3 days'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'voted', 'proposal', v_proposal_open, 'yes', now() - interval '2 days'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'filed', 'incident', v_incident_id, 'Saw left running', now() - interval '6 hours');
|
||||
|
||||
RAISE NOTICE 'Seed loaded. Space: % | invite code: DEMO-2026-TEST', v_space_id;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user