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,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;
|
||||
Reference in New Issue
Block a user