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>
This commit is contained in:
99
supabase/migrations/002_collectives.sql
Normal file
99
supabase/migrations/002_collectives.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- 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();
|
||||
Reference in New Issue
Block a user