- 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>
88 lines
3.3 KiB
PL/PgSQL
88 lines
3.3 KiB
PL/PgSQL
-- Migration 001: users table
|
|
-- Mirrors GoTrue auth.users — populated by trigger on first OIDC login.
|
|
|
|
-- ── Enums ─────────────────────────────────────────────────────────────────────
|
|
|
|
CREATE TYPE public.language_code AS ENUM ('en', 'es');
|
|
CREATE TYPE public.avatar_type AS ENUM ('initials', 'emoji', 'upload');
|
|
|
|
-- ── Table ─────────────────────────────────────────────────────────────────────
|
|
|
|
CREATE TABLE public.users (
|
|
id uuid PRIMARY KEY, -- matches auth.users.id (GoTrue UUID)
|
|
email text NOT NULL,
|
|
display_name text NOT NULL DEFAULT '',
|
|
language public.language_code NOT NULL DEFAULT 'en',
|
|
avatar_type public.avatar_type NOT NULL DEFAULT 'initials',
|
|
avatar_emoji text NULL,
|
|
avatar_url text NULL, -- signed URL from Supabase Storage
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- ── updated_at trigger ────────────────────────────────────────────────────────
|
|
|
|
CREATE OR REPLACE FUNCTION public.set_updated_at()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
NEW.updated_at = now();
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER users_set_updated_at
|
|
BEFORE UPDATE ON public.users
|
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
|
|
|
-- ── Sync trigger: GoTrue auth.users → public.users on first OIDC login ────────
|
|
-- GoTrue inserts into auth.users after a successful Keycloak OIDC exchange.
|
|
-- We mirror display_name and detect language from raw_user_meta_data claims.
|
|
|
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_display_name text;
|
|
v_language public.language_code;
|
|
v_raw jsonb;
|
|
BEGIN
|
|
v_raw := NEW.raw_user_meta_data;
|
|
|
|
-- Build display name: full_name > name > email prefix
|
|
v_display_name := COALESCE(
|
|
NULLIF(trim(v_raw->>'full_name'), ''),
|
|
NULLIF(trim(v_raw->>'name'), ''),
|
|
split_part(NEW.email, '@', 1)
|
|
);
|
|
|
|
-- Detect language from OIDC locale claim; default en
|
|
v_language := CASE
|
|
WHEN v_raw->>'locale' ILIKE 'es%' THEN 'es'::public.language_code
|
|
ELSE 'en'::public.language_code
|
|
END;
|
|
|
|
INSERT INTO public.users (id, email, display_name, language)
|
|
VALUES (NEW.id, NEW.email, v_display_name, v_language)
|
|
ON CONFLICT (id) DO UPDATE
|
|
SET email = EXCLUDED.email,
|
|
-- Preserve user-set display name; only backfill if still empty
|
|
display_name = CASE
|
|
WHEN public.users.display_name = '' THEN EXCLUDED.display_name
|
|
ELSE public.users.display_name
|
|
END;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER on_auth_user_created
|
|
AFTER INSERT ON auth.users
|
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|