From fda95d9ce45c124a1e6d3495cdfd51df0b1f43cf Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 30 Apr 2026 18:33:31 +0200 Subject: [PATCH] feat: add client auth tables --- .../__tests__/routes/clientInvites.test.ts | 118 +++++++++++++ packages/shared-types/src/database.types.ts | 159 ++++++++++++++++++ ...260501100000_create_client_auth_tables.sql | 73 ++++++++ 3 files changed, 350 insertions(+) create mode 100644 supabase/migrations/20260501100000_create_client_auth_tables.sql diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts index 5ffcefb..fd7d684 100644 --- a/apps/api/src/__tests__/routes/clientInvites.test.ts +++ b/apps/api/src/__tests__/routes/clientInvites.test.ts @@ -116,6 +116,24 @@ describe("Client Invites Endpoints", () => { } }; + const cleanupClientAuthByEmail = async (email: string) => { + const normalizedEmail = email.trim().toLowerCase(); + + const { data: client } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", normalizedEmail) + .maybeSingle(); + + if (!client?.id) { + return; + } + + await supabaseAdmin.from("client_magic_links").delete().eq("client_id", client.id); + await supabaseAdmin.from("client_access").delete().eq("client_id", client.id); + await supabaseAdmin.from("clients").delete().eq("id", client.id); + }; + const createClientAccount = async ( email: string, input?: { onboarded?: boolean; password?: string } @@ -160,6 +178,106 @@ describe("Client Invites Endpoints", () => { beforeEach(async () => { await cleanupInvitesByEmail(testEmail); await cleanupInvitesByEmail(existingClientEmail); + await cleanupClientAuthByEmail(testEmail); + await cleanupClientAuthByEmail(existingClientEmail); + }); + + it("upserts one global client identity per normalized email", async () => { + const mixedCaseEmail = "Test_Client_Invite_New@Example.com"; + + const res = await postInvite(ownerUser, adminTabloId, mixedCaseEmail); + + expect(res.status).toBe(200); + + const { data: clients, error } = await supabaseAdmin + .from("clients") + .select("id, email, normalized_email") + .eq("normalized_email", mixedCaseEmail.toLowerCase()); + + expect(error).toBeNull(); + expect(clients).toHaveLength(1); + expect(clients?.[0]?.email).toBe(mixedCaseEmail.toLowerCase()); + expect(clients?.[0]?.normalized_email).toBe(mixedCaseEmail.toLowerCase()); + }); + + it("creates a client_access grant for the invited tablo", async () => { + const res = await postInvite(ownerUser, adminTabloId, testEmail); + + expect(res.status).toBe(200); + + const { data: client } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", testEmail) + .single(); + + const { data: accessRows, error } = await supabaseAdmin + .from("client_access") + .select("id, tablo_id, client_id, revoked_at") + .eq("client_id", client.id) + .eq("tablo_id", adminTabloId); + + expect(error).toBeNull(); + expect(accessRows).toHaveLength(1); + expect(accessRows?.[0]?.revoked_at).toBeNull(); + }); + + it("creates a one-time invite magic link row", async () => { + const res = await postInvite(ownerUser, adminTabloId, testEmail); + + expect(res.status).toBe(200); + + const { data: client } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", testEmail) + .single(); + + const { data: links, error } = await supabaseAdmin + .from("client_magic_links") + .select("id, client_id, purpose, consumed_at, expires_at, created_by") + .eq("client_id", client.id) + .eq("purpose", "invite"); + + expect(error).toBeNull(); + expect(links).toHaveLength(1); + expect(links?.[0]?.consumed_at).toBeNull(); + expect(links?.[0]?.expires_at).toBeTruthy(); + expect(links?.[0]?.created_by).toBe(ownerUser.userId); + }); + + it("reuses the same client row when the same email is invited again", async () => { + const firstTabloId = adminTabloId; + const secondTabloId = "test_tablo_owner_shared"; + + const firstRes = await postInvite(ownerUser, firstTabloId, existingClientEmail); + expect(firstRes.status).toBe(200); + + const { data: firstClient } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", existingClientEmail) + .single(); + + const secondRes = await postInvite(ownerUser, secondTabloId, existingClientEmail); + expect(secondRes.status).toBe(200); + + const { data: secondClient } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", existingClientEmail) + .single(); + + const { data: accessRows, error } = await supabaseAdmin + .from("client_access") + .select("tablo_id") + .eq("client_id", firstClient.id); + + expect(error).toBeNull(); + expect(secondClient.id).toBe(firstClient.id); + expect(accessRows?.map((row) => row.tablo_id).sort()).toEqual( + [firstTabloId, secondTabloId].sort() + ); }); it("creates a setup token for a first-time client invite", async () => { diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index bfde7c8..36d49fd 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -78,6 +78,165 @@ export type Database = { }, ]; }; + client_access: { + Row: { + client_id: string; + created_at: string; + granted_at: string; + granted_by: string; + id: number; + revoked_at: string | null; + tablo_id: string; + }; + Insert: { + client_id: string; + created_at?: string; + granted_at?: string; + granted_by: string; + id?: number; + revoked_at?: string | null; + tablo_id: string; + }; + Update: { + client_id?: string; + created_at?: string; + granted_at?: string; + granted_by?: string; + id?: number; + revoked_at?: string | null; + tablo_id?: string; + }; + Relationships: [ + { + foreignKeyName: "client_access_client_id_fkey"; + columns: ["client_id"]; + isOneToOne: false; + referencedRelation: "clients"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_access_granted_by_fkey"; + columns: ["granted_by"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_access_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_access_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; + }, + { + foreignKeyName: "client_access_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; + }, + ]; + }; + client_magic_links: { + Row: { + client_id: string; + consumed_at: string | null; + created_at: string; + created_by: string | null; + email: string; + expires_at: string; + id: number; + jti: string | null; + purpose: string; + redirect_to: string | null; + token_hash: string | null; + }; + Insert: { + client_id: string; + consumed_at?: string | null; + created_at?: string; + created_by?: string | null; + email: string; + expires_at: string; + id?: number; + jti?: string | null; + purpose: string; + redirect_to?: string | null; + token_hash?: string | null; + }; + Update: { + client_id?: string; + consumed_at?: string | null; + created_at?: string; + created_by?: string | null; + email?: string; + expires_at?: string; + id?: number; + jti?: string | null; + purpose?: string; + redirect_to?: string | null; + token_hash?: string | null; + }; + Relationships: [ + { + foreignKeyName: "client_magic_links_client_id_fkey"; + columns: ["client_id"]; + isOneToOne: false; + referencedRelation: "clients"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_magic_links_created_by_fkey"; + columns: ["created_by"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; + }, + ]; + }; + clients: { + Row: { + created_at: string; + email: string; + first_name: string | null; + id: string; + last_login_at: string | null; + last_name: string | null; + normalized_email: string; + phone: string | null; + updated_at: string; + }; + Insert: { + created_at?: string; + email: string; + first_name?: string | null; + id?: string; + last_login_at?: string | null; + last_name?: string | null; + normalized_email: string; + phone?: string | null; + updated_at?: string; + }; + Update: { + created_at?: string; + email?: string; + first_name?: string | null; + id?: string; + last_login_at?: string | null; + last_name?: string | null; + normalized_email?: string; + phone?: string | null; + updated_at?: string; + }; + Relationships: []; + }; client_invites: { Row: { created_at: string; diff --git a/supabase/migrations/20260501100000_create_client_auth_tables.sql b/supabase/migrations/20260501100000_create_client_auth_tables.sql new file mode 100644 index 0000000..76fb653 --- /dev/null +++ b/supabase/migrations/20260501100000_create_client_auth_tables.sql @@ -0,0 +1,73 @@ +create table if not exists public.clients ( + id uuid primary key default gen_random_uuid(), + email text not null, + normalized_email text not null, + first_name text, + last_name text, + phone text, + last_login_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists clients_normalized_email_idx + on public.clients (normalized_email); + +create table if not exists public.client_access ( + id bigserial primary key, + client_id uuid not null references public.clients(id) on delete cascade, + tablo_id text not null references public.tablos(id) on delete cascade, + granted_by uuid not null references public.profiles(id), + granted_at timestamptz not null default now(), + revoked_at timestamptz, + created_at timestamptz not null default now() +); + +create unique index if not exists client_access_active_unique_idx + on public.client_access (client_id, tablo_id) + where revoked_at is null; + +create index if not exists client_access_client_id_idx + on public.client_access (client_id); + +create index if not exists client_access_tablo_id_idx + on public.client_access (tablo_id); + +create table if not exists public.client_magic_links ( + id bigserial primary key, + client_id uuid not null references public.clients(id) on delete cascade, + email text not null, + purpose text not null check (purpose in ('invite', 'login')), + token_hash text, + jti text, + redirect_to text, + expires_at timestamptz not null, + consumed_at timestamptz, + created_by uuid references public.profiles(id), + created_at timestamptz not null default now(), + constraint client_magic_links_token_reference_check check ( + token_hash is not null or jti is not null + ) +); + +create index if not exists client_magic_links_active_idx + on public.client_magic_links (client_id, purpose, expires_at) + where consumed_at is null; + +create unique index if not exists client_magic_links_jti_unique_idx + on public.client_magic_links (jti) + where jti is not null; + +create unique index if not exists client_magic_links_token_hash_unique_idx + on public.client_magic_links (token_hash) + where token_hash is not null; + +alter table public.clients enable row level security; +alter table public.client_access enable row level security; +alter table public.client_magic_links enable row level security; + +drop trigger if exists set_clients_updated_at on public.clients; +create trigger set_clients_updated_at +before update on public.clients +for each row +execute function public.set_updated_at();