Translate notifications

This commit is contained in:
Arthur Belleville 2025-11-16 22:28:07 +01:00
parent e1f673be47
commit d4afb0e9bb
No known key found for this signature in database
7 changed files with 386 additions and 21 deletions

View file

@ -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é");
});
});
});

View file

@ -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<string, string>)[locale] ||
(notification.message as Record<string, string>)["en"] ||
""
);
};
return (
<Link to={link} onClick={handleClick}>
<DropdownMenuItem className="cursor-pointer p-3 focus:bg-gray-700 hover:bg-gray-700 text-gray-200">
@ -137,7 +148,7 @@ function NotificationItem({ notification, onMarkAsRead, onClose }: NotificationI
</div>
<div className="flex-1 min-w-0">
<TypographySmall className="font-medium text-gray-100 line-clamp-2">
{notification.message}
{getMessage()}
</TypographySmall>
<TypographyMuted className="text-xs mt-1 text-gray-400">
{formatRelativeTime(notification.created_at)}

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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(

View file

@ -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