Files
collective-lists/supabase/migrations/002_collectives.sql
Oier Bravo Urtasun 7d91705fc2 feat(fase-1): auth, collective management, and DB migrations
- SQL migrations: users, collectives, collective_members, collective_invitations,
  full RLS policies, is_active_member/is_member/is_admin helpers,
  accept_invitation SECURITY DEFINER function, admin auto-promote trigger,
  avatars storage bucket
- Auth refactor: replace keycloak-js with Supabase OAuth (GoTrue OIDC proxy,
  Option B). signInWithOAuth({ provider: 'keycloak' }) + PKCE flow
- GoTrue config: GOTRUE_EXTERNAL_KEYCLOAK_URL + GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI
  added to docker-compose.dev.yml; Keycloak realm updated with GoTrue callback URI
- New routes: /auth/callback, /invitation/[token], /(app)/collective/manage
- Functional onboarding: create collective or join via invite link
- Full settings page: display name (debounced), avatar (initials/emoji/upload),
  language switcher, Keycloak account console link
- Components: Avatar.svelte (initials/emoji/upload with fallback),
  ImageCropper.svelte (cropperjs, 1:1 crop, lazy-loaded)
- i18n: added all Fase 1 message keys (en + es); renamed 'delete' → 'action_delete'
  (JS reserved word); fixed plugin-m-function-matcher major-version URL format
- Database types: manually seeded packages/types/src/database.ts from migrations
- .env.development: committed public dev defaults for SvelteKit type checking
- CLAUDE.md: documented /etc/hosts requirement for keycloak hostname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:29:29 +02:00

100 lines
4.4 KiB
PL/PgSQL

-- Migration 002: collectives, collective_members, collective_invitations
-- ── Enums ─────────────────────────────────────────────────────────────────────
CREATE TYPE public.member_role AS ENUM ('admin', 'member', 'guest');
-- ── collectives ───────────────────────────────────────────────────────────────
CREATE TABLE public.collectives (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
emoji text NOT NULL DEFAULT '🏠',
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.collectives ENABLE ROW LEVEL SECURITY;
-- ── collective_members ────────────────────────────────────────────────────────
CREATE TABLE public.collective_members (
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
role public.member_role NOT NULL DEFAULT 'member',
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (collective_id, user_id)
);
ALTER TABLE public.collective_members ENABLE ROW LEVEL SECURITY;
CREATE INDEX collective_members_user_idx ON public.collective_members (user_id);
-- ── collective_invitations ────────────────────────────────────────────────────
CREATE TABLE public.collective_invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
token uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
role public.member_role NOT NULL DEFAULT 'member',
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + INTERVAL '7 days'),
accepted_at timestamptz NULL,
accepted_by uuid NULL REFERENCES public.users(id) ON DELETE SET NULL
);
ALTER TABLE public.collective_invitations ENABLE ROW LEVEL SECURITY;
CREATE INDEX collective_invitations_token_idx ON public.collective_invitations (token);
CREATE INDEX collective_invitations_collective_idx ON public.collective_invitations (collective_id);
-- ── Trigger: auto-promote oldest member when last admin deletes account ────────
-- Fires BEFORE DELETE on public.users. If the departing user is the sole admin
-- of any collective, the longest-standing member (by joined_at) is promoted to
-- admin before the delete proceeds, satisfying business rule RN-10.
CREATE OR REPLACE FUNCTION public.promote_oldest_member_on_admin_leave()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
r RECORD;
BEGIN
-- For each collective where the departing user is an admin:
FOR r IN
SELECT cm.collective_id
FROM public.collective_members cm
WHERE cm.user_id = OLD.id
AND cm.role = 'admin'
-- Only act when this is the SOLE remaining admin
AND (
SELECT count(*)
FROM public.collective_members
WHERE collective_id = cm.collective_id
AND role = 'admin'
) = 1
LOOP
-- Promote the earliest-joined non-admin member in that collective
UPDATE public.collective_members
SET role = 'admin'
WHERE (collective_id, user_id) = (
SELECT collective_id, user_id
FROM public.collective_members
WHERE collective_id = r.collective_id
AND user_id <> OLD.id
ORDER BY joined_at ASC
LIMIT 1
);
END LOOP;
RETURN OLD;
END;
$$;
CREATE TRIGGER promote_on_admin_leave
BEFORE DELETE ON public.users
FOR EACH ROW EXECUTE FUNCTION public.promote_oldest_member_on_admin_leave();