From e1f673be473e1dc78112bcb58e2eaaa4a9769f03 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 16 Nov 2025 20:34:33 +0100 Subject: [PATCH] Add db migrations + tests --- apps/api/src/__tests__/routes/tablo.test.ts | 133 +++++++++ .../src/__tests__/routes/tablo_data.test.ts | 5 +- ...51116093421_create_notifications_table.sql | 5 +- .../20251116175557_update_notif_trigger.sql | 262 +++++++++++++++++ ...20251116185449_fix_actor_notif_trigger.sql | 270 ++++++++++++++++++ supabase/tests/README.md | 6 +- .../tests/database/09_notifications.test.sql | 269 ++++++++++++++++- 7 files changed, 941 insertions(+), 9 deletions(-) create mode 100644 supabase/migrations/20251116175557_update_notif_trigger.sql create mode 100644 supabase/migrations/20251116185449_fix_actor_notif_trigger.sql diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index b280297..43e376b 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -1,3 +1,4 @@ +import { createClient } from "@supabase/supabase-js"; import { testClient } from "hono/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createConfig } from "../../config.js"; @@ -35,6 +36,19 @@ vi.mock("stream-chat", () => { }; }); +// Mock nodemailer for email sending +const mockSendMail = vi.fn(); +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), + }, + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), +})); + describe("Tablo Endpoint", () => { // In test mode, createConfig() reads from .env.test const config = createConfig(); @@ -54,6 +68,7 @@ describe("Tablo Endpoint", () => { mockChannelDelete.mockResolvedValue(undefined); mockChannelRemoveMembers.mockResolvedValue(undefined); mockChannelAddMembers.mockResolvedValue(undefined); + mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); }); // Helper function to create tablo @@ -147,6 +162,28 @@ describe("Tablo Endpoint", () => { ); }; + // Helper function to invite user to tablo + const inviteToTabloRequest = async ( + user: TestUserData, + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + client: any, + tabloId: string, + email: string + ) => { + return await client.tablos.invite[":tabloId"].$post( + { + param: { tabloId }, + json: { email }, + }, + { + headers: { + Authorization: `Bearer ${user.accessToken}`, + "Content-Type": "application/json", + }, + } + ); + }; + describe("POST /tablos/create - Create Tablo", () => { it("should allow owner to create a tablo and create a Stream Chat channel", async () => { const res = await createTabloRequest(ownerUser, client, { @@ -436,4 +473,100 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(401); }); }); + + describe("POST /tablos/invite/:tabloId - Invite User to Tablo", () => { + it("should create a notification when inviting an existing user to a tablo", async () => { + // Create a Supabase client to query the database + const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); + + // Get temp user's email + const tempUserEmail = temporaryUser.email; + + // Count notifications for temp user before invite + const { data: notificationsBefore } = await supabaseAdmin + .from("notifications") + .select("*") + .eq("user_id", temporaryUser.userId) + .eq("entity_type", "tablo_invites"); + + const countBefore = notificationsBefore?.length || 0; + + // Owner invites temp user to owner's private tablo (temp doesn't have access yet) + const res = await inviteToTabloRequest( + ownerUser, + client, + "test_tablo_owner_private", + tempUserEmail + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.message).toContain("sent successfully"); + + // Query notifications table to verify notification was created + const { data: notificationsAfter } = await supabaseAdmin + .from("notifications") + .select("*") + .eq("user_id", temporaryUser.userId) + .eq("entity_type", "tablo_invites") + .order("created_at", { ascending: false }); + + expect(notificationsAfter).toBeDefined(); + expect(notificationsAfter?.length).toBeGreaterThan(countBefore); + + // Verify the latest notification has correct properties + const latestNotification = notificationsAfter?.[0]; + expect(latestNotification).toBeDefined(); + expect(latestNotification?.entity_type).toBe("tablo_invites"); + expect(latestNotification?.user_id).toBe(temporaryUser.userId); + expect(latestNotification?.actor_id).toBe(ownerUser.userId); + expect(latestNotification?.action_type).toBe("created"); + expect(latestNotification?.message).toContain("invited"); + expect(latestNotification?.read_at).toBeNull(); + }); + + it("should create notification when inviting non-existent user (creates temporary account)", async () => { + // Create a Supabase client to query the database + const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); + + const nonExistentEmail = "nonexistent_user_test@example.com"; + + // Owner invites non-existent user to owner's private tablo + const res = await inviteToTabloRequest( + ownerUser, + client, + "test_tablo_owner_private_2", + nonExistentEmail + ); + + // Request should succeed (creates invite) + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.message).toContain("sent successfully"); + + // Verify user was created with a temporary account + const { data: authUsers } = await supabaseAdmin.auth.admin.listUsers(); + const createdUser = authUsers.users.find( + (u: { email: string }) => u.email === nonExistentEmail + ); + expect(createdUser).toBeDefined(); + + // Check if notification was created for the newly created user + // Since the system creates a temporary account, a notification should be created + const { data: notificationsForInvite } = await supabaseAdmin + .from("notifications") + .select("*") + .eq("user_id", createdUser?.id) + .eq("entity_type", "tablo_invites") + .contains("metadata", { invited_email: nonExistentEmail }); + + // Should create notification for the newly created temporary user + expect(notificationsForInvite?.length || 0).toBeGreaterThan(0); + expect(notificationsForInvite?.[0].message).toContain("invited"); + }); + }); }); diff --git a/apps/api/src/__tests__/routes/tablo_data.test.ts b/apps/api/src/__tests__/routes/tablo_data.test.ts index 3b099e5..cb974d9 100644 --- a/apps/api/src/__tests__/routes/tablo_data.test.ts +++ b/apps/api/src/__tests__/routes/tablo_data.test.ts @@ -386,8 +386,9 @@ describe("TabloData Endpoint", () => { expect(data.message).toBe("File deleted successfully"); }); - it("should deny owner with admin access to delete file from temp's tablo", async () => { - // Owner does not have admin access to test_tablo_temp_shared_admin + it("should deny owner from deleting file from temp's tablo (regularUserCheck blocks temporary owner)", async () => { + // Owner has admin access to test_tablo_temp_shared_admin + // BUT regularUserCheck blocks access to tablos owned by temporary users const res = await deleteTabloFileRequest( ownerUser, client, diff --git a/supabase/migrations/20251116093421_create_notifications_table.sql b/supabase/migrations/20251116093421_create_notifications_table.sql index 8e15156..e9b8c52 100644 --- a/supabase/migrations/20251116093421_create_notifications_table.sql +++ b/supabase/migrations/20251116093421_create_notifications_table.sql @@ -248,11 +248,12 @@ BEGIN END IF; END IF; - -- Find the user by email and notify them + -- Find the user by email and notify them (case-insensitive) + -- Only notify if user exists in the system affected_users := ARRAY( SELECT id FROM auth.users - WHERE email = NEW.invited_email + WHERE LOWER(email) = LOWER(NEW.invited_email) AND id != actor ); diff --git a/supabase/migrations/20251116175557_update_notif_trigger.sql b/supabase/migrations/20251116175557_update_notif_trigger.sql new file mode 100644 index 0000000..122b903 --- /dev/null +++ b/supabase/migrations/20251116175557_update_notif_trigger.sql @@ -0,0 +1,262 @@ +CREATE OR REPLACE FUNCTION public.notify_users() +RETURNS TRIGGER AS $$ +DECLARE + affected_users UUID[]; + affected_user UUID; + actor UUID; + action TEXT; + msg TEXT; + meta JSONB; + entity_type_name TEXT; + entity_identifier TEXT; +BEGIN + -- Determine if this is an INSERT or UPDATE + IF TG_OP = 'INSERT' THEN + action := 'created'; + ELSE + action := 'updated'; + -- Skip if soft delete happened (only for tables with deleted_at column) + IF TG_TABLE_NAME IN ('tablos', 'events', 'notes', 'event_types') THEN + IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN + RETURN NEW; + END IF; + END IF; + END IF; + + -- Get the actor (current user) + actor := auth.uid(); + + -- Set entity type and ID based on the table + entity_type_name := TG_TABLE_NAME; + + -- Determine entity ID and affected users based on table + CASE TG_TABLE_NAME + WHEN 'tablos' THEN + entity_identifier := NEW.id; + + IF TG_OP = 'INSERT' THEN + msg := 'New tablo "' || NEW.name || '" was created'; + -- Notify owner (but not if they are the creator) + IF NEW.owner_id != actor THEN + affected_users := ARRAY[NEW.owner_id]; + END IF; + ELSE + msg := 'Tablo "' || NEW.name || '" was updated'; + -- Notify owner and all collaborators + affected_users := ARRAY( + SELECT DISTINCT user_id + FROM public.tablo_access + WHERE tablo_id = NEW.id + AND is_active = true + AND user_id != actor + UNION + SELECT NEW.owner_id + WHERE NEW.owner_id != actor + ); + END IF; + + meta := jsonb_build_object( + 'tablo_name', NEW.name, + 'status', NEW.status, + 'color', NEW.color + ); + + WHEN 'tasks' THEN + entity_identifier := NEW.id; + + IF TG_OP = 'INSERT' THEN + msg := 'New task "' || NEW.title || '" was created'; + ELSE + IF OLD.status != NEW.status THEN + msg := 'Task "' || NEW.title || '" status changed to ' || NEW.status; + ELSIF OLD.assignee_id != NEW.assignee_id OR (OLD.assignee_id IS NULL AND NEW.assignee_id IS NOT NULL) THEN + msg := 'You were assigned to task "' || NEW.title || '"'; + ELSE + msg := 'Task "' || NEW.title || '" was updated'; + END IF; + END IF; + + -- Notify tablo collaborators and assignee + affected_users := ARRAY( + SELECT DISTINCT user_id + FROM public.tablo_access + WHERE tablo_id = NEW.tablo_id + AND is_active = true + AND user_id != actor + UNION + SELECT t.owner_id + FROM public.tablos t + WHERE t.id = NEW.tablo_id + AND t.owner_id != actor + UNION + SELECT NEW.assignee_id + WHERE NEW.assignee_id IS NOT NULL + AND NEW.assignee_id != actor + ); + + meta := jsonb_build_object( + 'task_title', NEW.title, + 'status', NEW.status, + 'tablo_id', NEW.tablo_id, + 'assignee_id', NEW.assignee_id + ); + + WHEN 'events' THEN + entity_identifier := NEW.id; + + IF TG_OP = 'INSERT' THEN + msg := 'New event "' || NEW.title || '" was created'; + ELSE + msg := 'Event "' || NEW.title || '" was updated'; + END IF; + + -- Notify tablo collaborators + affected_users := ARRAY( + SELECT DISTINCT user_id + FROM public.tablo_access + WHERE tablo_id = NEW.tablo_id + AND is_active = true + AND user_id != actor + UNION + SELECT t.owner_id + FROM public.tablos t + WHERE t.id = NEW.tablo_id + AND t.owner_id != actor + UNION + SELECT NEW.created_by + WHERE NEW.created_by != actor + ); + + meta := jsonb_build_object( + 'event_title', NEW.title, + 'start_date', NEW.start_date, + 'start_time', NEW.start_time, + 'tablo_id', NEW.tablo_id + ); + + WHEN 'notes' THEN + entity_identifier := NEW.id; + + IF TG_OP = 'INSERT' THEN + msg := 'New note "' || NEW.title || '" was created'; + ELSE + msg := 'Note "' || NEW.title || '" was updated'; + END IF; + + -- Notify note owner and users with access + affected_users := ARRAY( + SELECT DISTINCT user_id + FROM public.note_access + WHERE note_id = NEW.id + AND is_active = true + AND user_id != actor + UNION + SELECT NEW.user_id + WHERE NEW.user_id != actor + ); + + meta := jsonb_build_object( + 'note_title', NEW.title, + 'note_owner', NEW.user_id + ); + + WHEN 'tablo_access' THEN + entity_identifier := NEW.id::TEXT; + + IF TG_OP = 'INSERT' THEN + msg := 'You were granted access to a tablo'; + ELSE + IF OLD.is_admin != NEW.is_admin AND NEW.is_admin = true THEN + msg := 'You were promoted to admin on a tablo'; + ELSIF OLD.is_active != NEW.is_active AND NEW.is_active = false THEN + msg := 'Your access to a tablo was revoked'; + ELSE + msg := 'Your tablo access was updated'; + END IF; + END IF; + + -- Notify the user being granted/modified access + IF NEW.user_id != actor THEN + affected_users := ARRAY[NEW.user_id]; + END IF; + + meta := jsonb_build_object( + 'tablo_id', NEW.tablo_id, + 'is_admin', NEW.is_admin, + 'is_active', NEW.is_active, + 'granted_by', NEW.granted_by + ); + + WHEN 'tablo_invites' THEN + entity_identifier := NEW.id::TEXT; + + RAISE LOG 'notify_users: Processing tablo_invites, TG_OP=%, email=%', TG_OP, NEW.invited_email; + + IF TG_OP = 'INSERT' THEN + msg := 'You were invited to collaborate on a tablo'; + ELSE + IF OLD.is_pending != NEW.is_pending AND NEW.is_pending = false THEN + msg := 'Your tablo invitation status changed'; + ELSE + msg := 'Your tablo invitation was updated'; + END IF; + END IF; + + RAISE LOG 'notify_users: Looking for user with email=%, actor=%', NEW.invited_email, actor; + + -- Find the user by email and notify them (case-insensitive) + -- Only notify if user exists in the system + affected_users := ARRAY( + SELECT id + FROM auth.users + WHERE LOWER(email) = LOWER(NEW.invited_email) + AND id != actor + ); + + RAISE LOG 'notify_users: Found % affected users for invite', COALESCE(array_length(affected_users, 1), 0); + + meta := jsonb_build_object( + 'tablo_id', NEW.tablo_id, + 'invited_email', NEW.invited_email, + 'invited_by', NEW.invited_by, + 'is_pending', NEW.is_pending + ); + + ELSE + -- Unknown table, skip + RETURN NEW; + END CASE; + + -- Insert notifications for all affected users + RAISE LOG 'notify_users: About to insert notifications for % users, entity_type=%', COALESCE(array_length(affected_users, 1), 0), entity_type_name; + + IF affected_users IS NOT NULL AND array_length(affected_users, 1) > 0 THEN + FOREACH affected_user IN ARRAY affected_users + LOOP + RAISE LOG 'notify_users: Inserting notification for user_id=%, entity_type=%, message=%', affected_user, entity_type_name, msg; + + INSERT INTO public.notifications ( + user_id, + actor_id, + entity_type, + entity_id, + action_type, + message, + metadata + ) VALUES ( + affected_user, + actor, + entity_type_name, + entity_identifier, + action, + msg, + meta + ); + END LOOP; + ELSE + RAISE LOG 'notify_users: No affected users to notify'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; \ No newline at end of file diff --git a/supabase/migrations/20251116185449_fix_actor_notif_trigger.sql b/supabase/migrations/20251116185449_fix_actor_notif_trigger.sql new file mode 100644 index 0000000..d00ce5e --- /dev/null +++ b/supabase/migrations/20251116185449_fix_actor_notif_trigger.sql @@ -0,0 +1,270 @@ +CREATE OR REPLACE FUNCTION public.notify_users() +RETURNS TRIGGER AS $$ +DECLARE + affected_users UUID[]; + affected_user UUID; + actor UUID; + action TEXT; + msg TEXT; + meta JSONB; + entity_type_name TEXT; + entity_identifier TEXT; +BEGIN + -- Determine if this is an INSERT or UPDATE + IF TG_OP = 'INSERT' THEN + action := 'created'; + ELSE + action := 'updated'; + -- Skip if soft delete happened (only for tables with deleted_at column) + IF TG_TABLE_NAME IN ('tablos', 'events', 'notes', 'event_types') THEN + IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN + RETURN NEW; + END IF; + END IF; + END IF; + + -- Get the actor (current user) + -- Note: auth.uid() may return NULL when called via service role (API calls) + -- In such cases, we won't exclude anyone from notifications based on actor + actor := auth.uid(); + + -- Set entity type and ID based on the table + entity_type_name := TG_TABLE_NAME; + + -- Determine entity ID and affected users based on table + CASE TG_TABLE_NAME + WHEN 'tablos' THEN + entity_identifier := NEW.id; + + IF TG_OP = 'INSERT' THEN + msg := 'New tablo "' || NEW.name || '" was created'; + -- Notify owner (but not if they are the creator) + IF actor IS NULL OR NEW.owner_id != actor THEN + affected_users := ARRAY[NEW.owner_id]; + END IF; + ELSE + msg := 'Tablo "' || NEW.name || '" was updated'; + -- Notify owner and all collaborators (exclude actor if set) + affected_users := ARRAY( + SELECT DISTINCT user_id + FROM public.tablo_access + WHERE tablo_id = NEW.id + AND is_active = true + AND (actor IS NULL OR user_id != actor) + UNION + SELECT NEW.owner_id + WHERE actor IS NULL OR NEW.owner_id != actor + ); + END IF; + + meta := jsonb_build_object( + 'tablo_name', NEW.name, + 'status', NEW.status, + 'color', NEW.color + ); + + WHEN 'tasks' THEN + entity_identifier := NEW.id; + + IF TG_OP = 'INSERT' THEN + msg := 'New task "' || NEW.title || '" was created'; + ELSE + IF OLD.status != NEW.status THEN + msg := 'Task "' || NEW.title || '" status changed to ' || NEW.status; + ELSIF OLD.assignee_id != NEW.assignee_id OR (OLD.assignee_id IS NULL AND NEW.assignee_id IS NOT NULL) THEN + msg := 'You were assigned to task "' || NEW.title || '"'; + ELSE + msg := 'Task "' || NEW.title || '" was updated'; + END IF; + END IF; + + -- Notify tablo collaborators and assignee (exclude actor if set) + affected_users := ARRAY( + SELECT DISTINCT user_id + FROM public.tablo_access + WHERE tablo_id = NEW.tablo_id + AND is_active = true + AND (actor IS NULL OR user_id != actor) + UNION + SELECT t.owner_id + FROM public.tablos t + WHERE t.id = NEW.tablo_id + AND (actor IS NULL OR t.owner_id != actor) + UNION + SELECT NEW.assignee_id + WHERE NEW.assignee_id IS NOT NULL + AND (actor IS NULL OR NEW.assignee_id != actor) + ); + + meta := jsonb_build_object( + 'task_title', NEW.title, + 'status', NEW.status, + 'tablo_id', NEW.tablo_id, + 'assignee_id', NEW.assignee_id + ); + + WHEN 'events' THEN + entity_identifier := NEW.id; + + IF TG_OP = 'INSERT' THEN + msg := 'New event "' || NEW.title || '" was created'; + ELSE + msg := 'Event "' || NEW.title || '" was updated'; + END IF; + + -- Notify tablo collaborators (exclude actor if set) + affected_users := ARRAY( + SELECT DISTINCT user_id + FROM public.tablo_access + WHERE tablo_id = NEW.tablo_id + AND is_active = true + AND (actor IS NULL OR user_id != actor) + UNION + SELECT t.owner_id + FROM public.tablos t + WHERE t.id = NEW.tablo_id + AND (actor IS NULL OR t.owner_id != actor) + UNION + SELECT NEW.created_by + WHERE actor IS NULL OR NEW.created_by != actor + ); + + meta := jsonb_build_object( + 'event_title', NEW.title, + 'start_date', NEW.start_date, + 'start_time', NEW.start_time, + 'tablo_id', NEW.tablo_id + ); + + WHEN 'notes' THEN + entity_identifier := NEW.id; + + IF TG_OP = 'INSERT' THEN + msg := 'New note "' || NEW.title || '" was created'; + ELSE + msg := 'Note "' || NEW.title || '" was updated'; + END IF; + + -- Notify note owner and users with access (exclude actor if set) + affected_users := ARRAY( + SELECT DISTINCT user_id + FROM public.note_access + WHERE note_id = NEW.id + AND is_active = true + AND (actor IS NULL OR user_id != actor) + UNION + SELECT NEW.user_id + WHERE actor IS NULL OR NEW.user_id != actor + ); + + meta := jsonb_build_object( + 'note_title', NEW.title, + 'note_owner', NEW.user_id + ); + + WHEN 'tablo_access' THEN + entity_identifier := NEW.id::TEXT; + + IF TG_OP = 'INSERT' THEN + msg := 'You were granted access to a tablo'; + ELSE + IF OLD.is_admin != NEW.is_admin AND NEW.is_admin = true THEN + msg := 'You were promoted to admin on a tablo'; + ELSIF OLD.is_active != NEW.is_active AND NEW.is_active = false THEN + msg := 'Your access to a tablo was revoked'; + ELSE + msg := 'Your tablo access was updated'; + END IF; + END IF; + + -- Notify the user being granted/modified access (exclude actor if set) + IF actor IS NULL OR NEW.user_id != actor THEN + affected_users := ARRAY[NEW.user_id]; + END IF; + + meta := jsonb_build_object( + 'tablo_id', NEW.tablo_id, + 'is_admin', NEW.is_admin, + 'is_active', NEW.is_active, + 'granted_by', NEW.granted_by + ); + + WHEN 'tablo_invites' THEN + entity_identifier := NEW.id::TEXT; + + -- For tablo_invites, use invited_by as actor instead of auth.uid() + -- because this is called via service role which has no auth context + actor := NEW.invited_by; + + RAISE LOG 'notify_users: New tablo invite: %', NEW; + RAISE LOG 'notify_users: Processing tablo_invites, TG_OP=%, email=%, actor=%', TG_OP, NEW.invited_email, actor; + + IF TG_OP = 'INSERT' THEN + msg := 'You were invited to collaborate on a tablo'; + ELSE + IF OLD.is_pending != NEW.is_pending AND NEW.is_pending = false THEN + msg := 'Your tablo invitation status changed'; + ELSE + msg := 'Your tablo invitation was updated'; + END IF; + END IF; + + RAISE LOG 'notify_users: Looking for user with email=%, actor=%', NEW.invited_email, actor; + + -- Find the user by email and notify them (case-insensitive) + -- Only notify if user exists in the system + -- Don't notify the person who sent the invite (exclude actor if set) + affected_users := ARRAY( + SELECT id + FROM auth.users + WHERE LOWER(email) = LOWER(NEW.invited_email) + AND (actor IS NULL OR id != actor) + ); + + RAISE LOG 'notify_users: Found % affected users for invite', COALESCE(array_length(affected_users, 1), 0); + + meta := jsonb_build_object( + 'tablo_id', NEW.tablo_id, + 'invited_email', NEW.invited_email, + 'invited_by', NEW.invited_by, + 'is_pending', NEW.is_pending + ); + + ELSE + -- Unknown table, skip + RETURN NEW; + END CASE; + + -- Insert notifications for all affected users + RAISE LOG 'notify_users: About to insert notifications for % users, entity_type=%', COALESCE(array_length(affected_users, 1), 0), entity_type_name; + + IF affected_users IS NOT NULL AND array_length(affected_users, 1) > 0 THEN + FOREACH affected_user IN ARRAY affected_users + LOOP + RAISE LOG 'notify_users: Inserting notification for user_id=%, entity_type=%, message=%', affected_user, entity_type_name, msg; + + INSERT INTO public.notifications ( + user_id, + actor_id, + entity_type, + entity_id, + action_type, + message, + metadata + ) VALUES ( + affected_user, + actor, + entity_type_name, + entity_identifier, + action, + msg, + meta + ); + END LOOP; + ELSE + RAISE LOG 'notify_users: No affected users to notify'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; \ No newline at end of file diff --git a/supabase/tests/README.md b/supabase/tests/README.md index d6ead74..78db88a 100644 --- a/supabase/tests/README.md +++ b/supabase/tests/README.md @@ -109,7 +109,7 @@ supabase/tests/database/03_rls_policies_notes.test.sql .. ok supabase/tests/database/09_notifications.test.sql .. ok supabase/tests/database/10_rls_policies_notifications.test.sql .. ok All tests successful. -Files=10, Tests=374, 1 wallclock secs +Files=10, Tests=382, 1 wallclock secs Result: PASS ``` @@ -155,7 +155,7 @@ Use the pgTAP documentation for available test functions: https://pgtap.org/docu ## Total Test Count -- **374 tests** across 10 test files (63 notifications schema/triggers + 19 notifications RLS) +- **382 tests** across 10 test files (71 notifications schema/triggers/NULL-actor + 19 notifications RLS) - Comprehensive coverage of all database components - Security-focused testing for RLS and permissions -- Full notification system coverage including triggers and RLS +- Full notification system coverage including triggers, RLS, and NULL actor (service role) scenarios diff --git a/supabase/tests/database/09_notifications.test.sql b/supabase/tests/database/09_notifications.test.sql index 41c1142..5a63104 100644 --- a/supabase/tests/database/09_notifications.test.sql +++ b/supabase/tests/database/09_notifications.test.sql @@ -1,5 +1,5 @@ begin; -select plan(63); -- Total number of tests (54 original + 9 multiple notifications tests) +select plan(71); -- Total number of tests (54 original + 9 multiple notifications tests + 2 invite edge cases + 6 null actor tests) -- ============================================================================ -- Table Existence Tests @@ -336,7 +336,77 @@ SELECT ok( (SELECT COUNT(*) FROM public.notifications WHERE entity_type = 'tablo_invites' AND user_id = current_setting('test.user2_id')::uuid) > 0, - 'Notification should be created when user is invited' + 'Notification should be created when existing user is invited' +); + +-- Test: No notification for non-existent user +DO $$ +DECLARE + count_before bigint; + count_after bigint; +BEGIN + PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true); + + SELECT COUNT(*) INTO count_before FROM public.notifications; + + -- Invite a non-existent user + INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending) + VALUES ( + current_setting('test.tablo_id'), + 'nonexistent@example.com', + current_setting('test.user1_id')::uuid, + 'test-token-' || gen_random_uuid()::text, + true + ); + + SELECT COUNT(*) INTO count_after FROM public.notifications; + + PERFORM set_config('test.nonexistent_notif_diff', (count_after - count_before)::text, true); +END $$; + +SELECT is( + current_setting('test.nonexistent_notif_diff', true)::integer, + 0, + 'No notification should be created when inviting non-existent user' +); + +-- Test: Case-insensitive email matching +DO $$ +DECLARE + count_before bigint; + count_after bigint; +BEGIN + PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true); + + -- Count notifications for user2 before + SELECT COUNT(*) INTO count_before + FROM public.notifications + WHERE entity_type = 'tablo_invites' + AND user_id = current_setting('test.user2_id')::uuid; + + -- Invite with uppercase email to test case-insensitivity + INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending) + VALUES ( + current_setting('test.tablo_id'), + UPPER('notiftest2_' || current_setting('test.user2_id') || '@test.com'), + current_setting('test.user1_id')::uuid, + 'test-token-uppercase-' || gen_random_uuid()::text, + true + ); + + -- Count after + SELECT COUNT(*) INTO count_after + FROM public.notifications + WHERE entity_type = 'tablo_invites' + AND user_id = current_setting('test.user2_id')::uuid; + + PERFORM set_config('test.case_insensitive_notif_diff', (count_after - count_before)::text, true); +END $$; + +SELECT is( + current_setting('test.case_insensitive_notif_diff', true)::integer, + 1, + 'Notification should be created with case-insensitive email matching' ); -- ============================================================================ @@ -595,5 +665,200 @@ SELECT is( 'Soft delete should not create additional notifications' ); +-- ============================================================================ +-- NULL Actor Tests (Service Role / API Operations) +-- ============================================================================ + +-- Test: Tablo update with NULL actor should notify all collaborators +DO $$ +DECLARE + notif_count_before bigint; + notif_count_after bigint; +BEGIN + -- Clear auth context (simulates service role call with no auth.uid()) + PERFORM set_config('request.jwt.claims', NULL, true); + + -- Count notifications before update + SELECT COUNT(*) INTO notif_count_before FROM public.notifications; + + -- Update tablo without actor context + UPDATE public.tablos + SET name = 'Updated by Service Role' + WHERE id = current_setting('test.tablo_id'); + + -- Count notifications after update + SELECT COUNT(*) INTO notif_count_after FROM public.notifications; + + PERFORM set_config('test.null_actor_notif_count', (notif_count_after - notif_count_before)::text, true); +END $$; + +SELECT is( + current_setting('test.null_actor_notif_count', true)::integer, + 5, + 'With NULL actor, all collaborators (including owner) should be notified on tablo update (5 total: user1-5)' +); + +-- Test: Task creation with NULL actor +DO $$ +DECLARE + notif_count_before bigint; + notif_count_after bigint; + new_task_id text; +BEGIN + PERFORM set_config('request.jwt.claims', NULL, true); + + SELECT COUNT(*) INTO notif_count_before FROM public.notifications; + + -- Create task without actor context + INSERT INTO public.tasks (tablo_id, title, description, status, position) + VALUES (current_setting('test.tablo_id'), 'Service Role Task', 'Created via service role', 'todo', 2) + RETURNING id INTO new_task_id; + + SELECT COUNT(*) INTO notif_count_after FROM public.notifications; + + PERFORM set_config('test.null_actor_task_notif_count', (notif_count_after - notif_count_before)::text, true); + PERFORM set_config('test.null_actor_task_id', new_task_id, true); +END $$; + +SELECT is( + current_setting('test.null_actor_task_notif_count', true)::integer, + 5, + 'With NULL actor, all tablo members should be notified on task creation (owner + 4 collaborators)' +); + +-- Test: Tablo access grant with NULL actor +DO $$ +DECLARE + test_user6_id uuid := gen_random_uuid(); + notif_count_before bigint; + notif_count_after bigint; +BEGIN + -- Insert test user6 + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at) + VALUES + (test_user6_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notiftest6_' || test_user6_id::text || '@test.com', 'encrypted', now(), now(), now()) + ON CONFLICT DO NOTHING; + + INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id) + VALUES + (test_user6_id, 'notiftest6_' || test_user6_id::text || '@test.com', 'NotifTest', 'User6', substring(test_user6_id::text from 1 for 8)) + ON CONFLICT DO NOTHING; + + PERFORM set_config('test.user6_id', test_user6_id::text, true); + PERFORM set_config('request.jwt.claims', NULL, true); + + SELECT COUNT(*) INTO notif_count_before + FROM public.notifications + WHERE user_id = test_user6_id; + + -- Grant access without actor context + INSERT INTO public.tablo_access (tablo_id, user_id, granted_by, is_active, is_admin) + VALUES (current_setting('test.tablo_id'), test_user6_id, current_setting('test.user1_id')::uuid, true, false); + + SELECT COUNT(*) INTO notif_count_after + FROM public.notifications + WHERE user_id = test_user6_id; + + PERFORM set_config('test.null_actor_access_notif_diff', (notif_count_after - notif_count_before)::text, true); +END $$; + +SELECT is( + current_setting('test.null_actor_access_notif_diff', true)::integer, + 1, + 'With NULL actor, user should still be notified when granted tablo access' +); + +-- Test: Tablo invite with NULL actor (simulating API invite) +DO $$ +DECLARE + notif_count_before bigint; + notif_count_after bigint; +BEGIN + PERFORM set_config('request.jwt.claims', NULL, true); + + -- Count notifications for user6 before invite + SELECT COUNT(*) INTO notif_count_before + FROM public.notifications + WHERE user_id = current_setting('test.user6_id')::uuid + AND entity_type = 'tablo_invites'; + + -- Invite user6 without actor context (via service role) + INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending) + VALUES ( + current_setting('test.tablo_id'), + 'notiftest6_' || current_setting('test.user6_id') || '@test.com', + current_setting('test.user1_id')::uuid, + 'test-token-null-actor-' || gen_random_uuid()::text, + true + ); + + -- Count after + SELECT COUNT(*) INTO notif_count_after + FROM public.notifications + WHERE user_id = current_setting('test.user6_id')::uuid + AND entity_type = 'tablo_invites'; + + PERFORM set_config('test.null_actor_invite_notif_diff', (notif_count_after - notif_count_before)::text, true); +END $$; + +SELECT is( + current_setting('test.null_actor_invite_notif_diff', true)::integer, + 1, + 'With NULL actor, invited user should still receive notification (API-initiated invite)' +); + +-- Test: Event creation with NULL actor +DO $$ +DECLARE + notif_count_before bigint; + notif_count_after bigint; + new_event_id text; +BEGIN + PERFORM set_config('request.jwt.claims', NULL, true); + + SELECT COUNT(*) INTO notif_count_before FROM public.notifications; + + -- Create event without actor context + INSERT INTO public.events (tablo_id, title, description, start_date, start_time, created_by) + VALUES (current_setting('test.tablo_id'), 'Service Role Event', 'Event via service role', CURRENT_DATE, '14:00', current_setting('test.user1_id')::uuid) + RETURNING id INTO new_event_id; + + SELECT COUNT(*) INTO notif_count_after FROM public.notifications; + + PERFORM set_config('test.null_actor_event_notif_count', (notif_count_after - notif_count_before)::text, true); +END $$; + +SELECT is( + current_setting('test.null_actor_event_notif_count', true)::integer, + 6, + 'With NULL actor, all tablo members should be notified on event creation (owner + 5 collaborators including user6)' +); + +-- Test: Note update with NULL actor +DO $$ +DECLARE + notif_count_before bigint; + notif_count_after bigint; +BEGIN + PERFORM set_config('request.jwt.claims', NULL, true); + + SELECT COUNT(*) INTO notif_count_before FROM public.notifications; + + -- Update note without actor context + UPDATE public.notes + SET content = 'Updated by service role' + WHERE id = current_setting('test.note_id'); + + SELECT COUNT(*) INTO notif_count_after FROM public.notifications; + + PERFORM set_config('test.null_actor_note_notif_count', (notif_count_after - notif_count_before)::text, true); +END $$; + +SELECT is( + current_setting('test.null_actor_note_notif_count', true)::integer, + 2, + 'With NULL actor, note owner (user1) and collaborator (user2) should both be notified on note update' +); + select * from finish(); rollback;