From d4afb0e9bb72d052011e2bf7251e67e8b6a2a532 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 16 Nov 2025 22:28:07 +0100 Subject: [PATCH] Translate notifications --- apps/api/src/__tests__/routes/tablo.test.ts | 9 +- .../main/src/components/NotificationPanel.tsx | 13 +- packages/shared-types/src/database.types.ts | 6 +- ...1132_remove_notice_and_add_translation.sql | 344 ++++++++++++++++++ .../tests/database/09_notifications.test.sql | 24 +- .../10_rls_policies_notifications.test.sql | 5 +- xtablo-expo/lib/database.types.ts | 6 +- 7 files changed, 386 insertions(+), 21 deletions(-) create mode 100644 supabase/migrations/20251116211132_remove_notice_and_add_translation.sql diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index 43e376b..79fe535 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -523,7 +523,10 @@ describe("Tablo Endpoint", () => { 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"); + // Message is now a JSONB object with en/fr keys + expect(latestNotification?.message).toBeDefined(); + expect((latestNotification?.message as any)?.en).toContain("invited"); + expect((latestNotification?.message as any)?.fr).toContain("invité"); expect(latestNotification?.read_at).toBeNull(); }); @@ -566,7 +569,9 @@ describe("Tablo Endpoint", () => { // Should create notification for the newly created temporary user expect(notificationsForInvite?.length || 0).toBeGreaterThan(0); - expect(notificationsForInvite?.[0].message).toContain("invited"); + // Message is now a JSONB object with en/fr keys + expect((notificationsForInvite?.[0].message as any)?.en).toContain("invited"); + expect((notificationsForInvite?.[0].message as any)?.fr).toContain("invité"); }); }); }); diff --git a/apps/main/src/components/NotificationPanel.tsx b/apps/main/src/components/NotificationPanel.tsx index 1559770..f3e24f8 100644 --- a/apps/main/src/components/NotificationPanel.tsx +++ b/apps/main/src/components/NotificationPanel.tsx @@ -119,6 +119,7 @@ interface NotificationItemProps { } function NotificationItem({ notification, onMarkAsRead, onClose }: NotificationItemProps) { + const { i18n } = useTranslation(); const link = getNotificationLink(notification); const handleClick = () => { @@ -126,6 +127,16 @@ function NotificationItem({ notification, onMarkAsRead, onClose }: NotificationI onClose(); }; + // Extract the message based on current locale + const getMessage = () => { + const locale = i18n.language.startsWith("fr") ? "fr" : "en"; + return ( + (notification.message as Record)[locale] || + (notification.message as Record)["en"] || + "" + ); + }; + return ( @@ -137,7 +148,7 @@ function NotificationItem({ notification, onMarkAsRead, onClose }: NotificationI
- {notification.message} + {getMessage()} {formatRelativeTime(notification.created_at)} diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index 00f3241..0185db5 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -353,7 +353,7 @@ export type Database = { entity_id: string entity_type: string id: string - message: string + message: Json metadata: Json | null read_at: string | null user_id: string @@ -365,7 +365,7 @@ export type Database = { entity_id: string entity_type: string id?: string - message: string + message?: Json metadata?: Json | null read_at?: string | null user_id: string @@ -377,7 +377,7 @@ export type Database = { entity_id?: string entity_type?: string id?: string - message?: string + message?: Json metadata?: Json | null read_at?: string | null user_id?: string diff --git a/supabase/migrations/20251116211132_remove_notice_and_add_translation.sql b/supabase/migrations/20251116211132_remove_notice_and_add_translation.sql new file mode 100644 index 0000000..c6890d4 --- /dev/null +++ b/supabase/migrations/20251116211132_remove_notice_and_add_translation.sql @@ -0,0 +1,344 @@ +-- ============================================================================ +-- Step 1: Alter the message column to JSONB +-- ============================================================================ + +-- First, migrate existing data (if any) to JSONB format with en/fr keys +ALTER TABLE public.notifications +ADD COLUMN message_jsonb JSONB; + +-- Migrate existing English messages to JSONB format +-- This assumes existing messages are in English +UPDATE public.notifications +SET message_jsonb = jsonb_build_object( + 'en', message, + 'fr', message -- Placeholder, will be replaced by trigger going forward +); + +-- Drop old column and rename new one +ALTER TABLE public.notifications +DROP COLUMN message; + +ALTER TABLE public.notifications +RENAME COLUMN message_jsonb TO message; + +-- Make it NOT NULL +ALTER TABLE public.notifications +ALTER COLUMN message SET NOT NULL; + +-- Set default to empty object +ALTER TABLE public.notifications +ALTER COLUMN message SET DEFAULT '{}'::jsonb; + +-- ============================================================================ +-- Step 2: Update the notify_users function with translations and no RAISE LOG +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.notify_users() +RETURNS TRIGGER AS $$ +DECLARE + affected_users UUID[]; + affected_user UUID; + actor UUID; + action TEXT; + msg JSONB; -- Changed from TEXT to JSONB + 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 := jsonb_build_object( + 'en', 'New tablo "' || NEW.name || '" was created', + 'fr', 'Nouveau tablo "' || NEW.name || '" a été créé' + ); + -- 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 := jsonb_build_object( + 'en', 'Tablo "' || NEW.name || '" was updated', + 'fr', 'Le tablo "' || NEW.name || '" a été mis à jour' + ); + -- 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 := jsonb_build_object( + 'en', 'New task "' || NEW.title || '" was created', + 'fr', 'Nouvelle tâche "' || NEW.title || '" a été créée' + ); + ELSE + IF OLD.status != NEW.status THEN + msg := jsonb_build_object( + 'en', 'Task "' || NEW.title || '" status changed to ' || NEW.status, + 'fr', 'Le statut de la tâche "' || NEW.title || '" a changé à ' || NEW.status + ); + ELSIF OLD.assignee_id != NEW.assignee_id OR (OLD.assignee_id IS NULL AND NEW.assignee_id IS NOT NULL) THEN + msg := jsonb_build_object( + 'en', 'You were assigned to task "' || NEW.title || '"', + 'fr', 'Vous avez été assigné à la tâche "' || NEW.title || '"' + ); + ELSE + msg := jsonb_build_object( + 'en', 'Task "' || NEW.title || '" was updated', + 'fr', 'La tâche "' || NEW.title || '" a été mise à jour' + ); + 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 := jsonb_build_object( + 'en', 'New event "' || NEW.title || '" was created', + 'fr', 'Nouvel événement "' || NEW.title || '" a été créé' + ); + ELSE + msg := jsonb_build_object( + 'en', 'Event "' || NEW.title || '" was updated', + 'fr', 'L''événement "' || NEW.title || '" a été mis à jour' + ); + 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 := jsonb_build_object( + 'en', 'New note "' || NEW.title || '" was created', + 'fr', 'Nouvelle note "' || NEW.title || '" a été créée' + ); + ELSE + msg := jsonb_build_object( + 'en', 'Note "' || NEW.title || '" was updated', + 'fr', 'La note "' || NEW.title || '" a été mise à jour' + ); + 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 := jsonb_build_object( + 'en', 'You were granted access to a tablo', + 'fr', 'Vous avez obtenu l''accès à un tablo' + ); + ELSE + IF OLD.is_admin != NEW.is_admin AND NEW.is_admin = true THEN + msg := jsonb_build_object( + 'en', 'You were promoted to admin on a tablo', + 'fr', 'Vous avez été promu administrateur d''un tablo' + ); + ELSIF OLD.is_active != NEW.is_active AND NEW.is_active = false THEN + msg := jsonb_build_object( + 'en', 'Your access to a tablo was revoked', + 'fr', 'Votre accès à un tablo a été révoqué' + ); + ELSE + msg := jsonb_build_object( + 'en', 'Your tablo access was updated', + 'fr', 'Votre accès au tablo a été mis à jour' + ); + 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; + + IF TG_OP = 'INSERT' THEN + msg := jsonb_build_object( + 'en', 'You were invited to collaborate on a tablo', + 'fr', 'Vous avez été invité à collaborer sur un tablo' + ); + ELSE + IF OLD.is_pending != NEW.is_pending AND NEW.is_pending = false THEN + msg := jsonb_build_object( + 'en', 'Your tablo invitation status changed', + 'fr', 'Le statut de votre invitation au tablo a changé' + ); + ELSE + msg := jsonb_build_object( + 'en', 'Your tablo invitation was updated', + 'fr', 'Votre invitation au tablo a été mise à jour' + ); + END IF; + END IF; + + -- 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) + ); + + 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 + IF affected_users IS NOT NULL AND array_length(affected_users, 1) > 0 THEN + FOREACH affected_user IN ARRAY affected_users + LOOP + 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; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + diff --git a/supabase/tests/database/09_notifications.test.sql b/supabase/tests/database/09_notifications.test.sql index 5a63104..db99ae7 100644 --- a/supabase/tests/database/09_notifications.test.sql +++ b/supabase/tests/database/09_notifications.test.sql @@ -28,7 +28,7 @@ SELECT col_type_is('public', 'notifications', 'actor_id', 'uuid', 'notifications SELECT col_type_is('public', 'notifications', 'entity_type', 'text', 'notifications.entity_type should be text'); SELECT col_type_is('public', 'notifications', 'entity_id', 'text', 'notifications.entity_id should be text'); SELECT col_type_is('public', 'notifications', 'action_type', 'text', 'notifications.action_type should be text'); -SELECT col_type_is('public', 'notifications', 'message', 'text', 'notifications.message should be text'); +SELECT col_type_is('public', 'notifications', 'message', 'jsonb', 'notifications.message should be jsonb'); SELECT col_type_is('public', 'notifications', 'metadata', 'jsonb', 'notifications.metadata should be jsonb'); SELECT col_type_is('public', 'notifications', 'read_at', 'timestamp with time zone', 'notifications.read_at should be timestamptz'); SELECT col_type_is('public', 'notifications', 'created_at', 'timestamp with time zone', 'notifications.created_at should be timestamptz'); @@ -478,7 +478,7 @@ SELECT is( (SELECT COUNT(*)::integer FROM public.notifications WHERE entity_type = 'tablos' AND entity_id = current_setting('test.tablo_id') - AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated' + AND message->>'en' = 'Tablo "Tablo Updated for Multiple Users Test" was updated' AND user_id = current_setting('test.user2_id')::uuid), 1, 'User2 should receive exactly 1 notification for tablo update' @@ -488,7 +488,7 @@ SELECT is( (SELECT COUNT(*)::integer FROM public.notifications WHERE entity_type = 'tablos' AND entity_id = current_setting('test.tablo_id') - AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated' + AND message->>'en' = 'Tablo "Tablo Updated for Multiple Users Test" was updated' AND user_id = current_setting('test.user4_id')::uuid), 1, 'User4 should receive exactly 1 notification for tablo update' @@ -498,7 +498,7 @@ SELECT is( (SELECT COUNT(*)::integer FROM public.notifications WHERE entity_type = 'tablos' AND entity_id = current_setting('test.tablo_id') - AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated' + AND message->>'en' = 'Tablo "Tablo Updated for Multiple Users Test" was updated' AND user_id = current_setting('test.user5_id')::uuid), 1, 'User5 should receive exactly 1 notification for tablo update' @@ -508,7 +508,7 @@ SELECT is( (SELECT COUNT(*)::integer FROM public.notifications WHERE entity_type = 'tablos' AND entity_id = current_setting('test.tablo_id') - AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated' + AND message->>'en' = 'Tablo "Tablo Updated for Multiple Users Test" was updated' AND user_id = current_setting('test.user3_id')::uuid), 1, 'User3 should receive exactly 1 notification for tablo update' @@ -519,7 +519,7 @@ SELECT is( (SELECT COUNT(*)::integer FROM public.notifications WHERE entity_type = 'tablos' AND entity_id = current_setting('test.tablo_id') - AND message = 'Tablo "Tablo Updated for Multiple Users Test" was updated' + AND message->>'en' = 'Tablo "Tablo Updated for Multiple Users Test" was updated' AND actor_id = current_setting('test.user1_id')::uuid AND user_id = current_setting('test.user1_id')::uuid), 0, @@ -592,7 +592,7 @@ SELECT is( WHERE entity_type = 'tasks' AND entity_id = current_setting('test.multi_task_id') AND user_id = current_setting('test.user5_id')::uuid - AND message LIKE '%assigned%'), + AND message->>'en' LIKE '%assigned%'), 1, 'Assignee should receive exactly 1 notification about task assignment' ); @@ -608,10 +608,14 @@ SELECT ok( 'Notification metadata should be valid JSONB objects' ); --- Test: Messages should be non-empty +-- Test: Messages should be non-empty and have both en and fr translations SELECT ok( - (SELECT COUNT(*) FROM public.notifications WHERE length(message) = 0) = 0, - 'All notification messages should be non-empty' + (SELECT COUNT(*) FROM public.notifications + WHERE length(message->>'en') = 0 + OR length(message->>'fr') = 0 + OR message->>'en' IS NULL + OR message->>'fr' IS NULL) = 0, + 'All notification messages should have non-empty en and fr translations' ); -- Test: Action types should be valid diff --git a/supabase/tests/database/10_rls_policies_notifications.test.sql b/supabase/tests/database/10_rls_policies_notifications.test.sql index 30d0881..a96c2e6 100644 --- a/supabase/tests/database/10_rls_policies_notifications.test.sql +++ b/supabase/tests/database/10_rls_policies_notifications.test.sql @@ -135,8 +135,9 @@ SELECT ok( (SELECT COUNT(*) FROM public.notifications WHERE user_id = current_setting('test.rls_user2_id')::uuid AND message IS NOT NULL - AND length(message) > 0) > 0, - 'Notifications should have non-empty messages' + AND length(message->>'en') > 0 + AND length(message->>'fr') > 0) > 0, + 'Notifications should have non-empty messages in both en and fr' ); SELECT ok( diff --git a/xtablo-expo/lib/database.types.ts b/xtablo-expo/lib/database.types.ts index 00f3241..0185db5 100644 --- a/xtablo-expo/lib/database.types.ts +++ b/xtablo-expo/lib/database.types.ts @@ -353,7 +353,7 @@ export type Database = { entity_id: string entity_type: string id: string - message: string + message: Json metadata: Json | null read_at: string | null user_id: string @@ -365,7 +365,7 @@ export type Database = { entity_id: string entity_type: string id?: string - message: string + message?: Json metadata?: Json | null read_at?: string | null user_id: string @@ -377,7 +377,7 @@ export type Database = { entity_id?: string entity_type?: string id?: string - message?: string + message?: Json metadata?: Json | null read_at?: string | null user_id?: string