- 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>
100 lines
4.4 KiB
PL/PgSQL
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();
|