From d583dbb945333ec5e414c8b1e1cd13a13468475c Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Wed, 18 Feb 2026 22:42:02 +0100 Subject: [PATCH] Implement notification template system and bulk notification creation --- backend/api/package.json | 2 +- backend/api/src/create-notification.ts | 101 ++++++++++--- backend/api/src/get-notifications.ts | 45 +++++- backend/shared/src/supabase/notifications.ts | 137 +++++++++++++++++- .../20260218_add_notification_templates.sql | 47 ++++++ common/src/notifications.ts | 28 +++- web/components/widgets/linkify.tsx | 14 +- 7 files changed, 331 insertions(+), 43 deletions(-) create mode 100644 backend/supabase/migrations/20260218_add_notification_templates.sql diff --git a/backend/api/package.json b/backend/api/package.json index 03178dc8..67ef0f62 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -1,7 +1,7 @@ { "name": "@compass/api", "description": "Backend API endpoints", - "version": "1.8.0", + "version": "1.9.0", "private": true, "scripts": { "watch:serve": "tsx watch src/serve.ts", diff --git a/backend/api/src/create-notification.ts b/backend/api/src/create-notification.ts index 957b6e1f..55a7cfdd 100644 --- a/backend/api/src/create-notification.ts +++ b/backend/api/src/create-notification.ts @@ -1,12 +1,12 @@ -import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init' +import {createSupabaseDirectClient, SupabaseDirectClient,} from 'shared/supabase/init' import {Notification} from 'common/notifications' -import {insertNotificationToSupabase} from 'shared/supabase/notifications' -import {tryCatch} from "common/util/try-catch"; -import {Row} from "common/supabase/utils"; -import {ANDROID_APP_URL} from "common/constants"; +import {createBulkNotification, insertNotificationToSupabase,} from 'shared/supabase/notifications' +import {tryCatch} from 'common/util/try-catch' +import {Row} from 'common/supabase/utils' +import {ANDROID_APP_URL} from 'common/constants' export const createAndroidReleaseNotifications = async () => { - const createdTime = Date.now(); + const createdTime = Date.now() const id = `android-release-${createdTime}` const notification: Notification = { id, @@ -16,15 +16,17 @@ export const createAndroidReleaseNotifications = async () => { sourceType: 'info', sourceUpdateType: 'created', sourceSlug: ANDROID_APP_URL, - sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185', + sourceUserAvatarUrl: + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185', title: 'Android App Released on Google Play', - sourceText: 'The Compass Android app is now publicly available on Google Play! Download it today to stay connected on the go.', + sourceText: + 'The Compass Android app is now publicly available on Google Play! Download it today to stay connected on the go.', } return await createNotifications(notification) } export const createAndroidTestNotifications = async () => { - const createdTime = Date.now(); + const createdTime = Date.now() const id = `android-test-${createdTime}` const notification: Notification = { id, @@ -34,15 +36,18 @@ export const createAndroidTestNotifications = async () => { sourceType: 'info', sourceUpdateType: 'created', sourceSlug: '/contact', - sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185', - title: 'Android App Ready for Review — Help Us Unlock the Google Play Release', - sourceText: 'To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Play–registered email address so we can add you as a tester and complete the review process.', + sourceUserAvatarUrl: + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185', + title: + 'Android App Ready for Review — Help Us Unlock the Google Play Release', + sourceText: + 'To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Play–registered email address so we can add you as a tester and complete the review process.', } return await createNotifications(notification) } export const createShareNotifications = async () => { - const createdTime = Date.now(); + const createdTime = Date.now() const id = `share-${createdTime}` const notification: Notification = { id, @@ -52,15 +57,17 @@ export const createShareNotifications = async () => { sourceType: 'info', sourceUpdateType: 'created', sourceSlug: '/contact', - sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Ficon-outreach-outstrip-outreach-272151502.jpg?alt=media&token=6d6fcecb-818c-4fca-a8e0-d2d0069b9445', + sourceUserAvatarUrl: + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Ficon-outreach-outstrip-outreach-272151502.jpg?alt=media&token=6d6fcecb-818c-4fca-a8e0-d2d0069b9445', title: 'Give us tips to reach more people', - sourceText: '250 members already! Tell us where and how we can best share Compass.', + sourceText: + '250 members already! Tell us where and how we can best share Compass.', } return await createNotifications(notification) } export const createVoteNotifications = async () => { - const createdTime = Date.now(); + const createdTime = Date.now() const id = `vote-${createdTime}` const notification: Notification = { id, @@ -70,9 +77,10 @@ export const createVoteNotifications = async () => { sourceType: 'info', sourceUpdateType: 'created', sourceSlug: '/vote', - sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fvote-icon-design-free-vector.jpg?alt=media&token=f70b6d14-0511-49b2-830d-e7cabf7bb751', + sourceUserAvatarUrl: + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fvote-icon-design-free-vector.jpg?alt=media&token=f70b6d14-0511-49b2-830d-e7cabf7bb751', title: 'New Proposals & Votes Page', - sourceText: 'Create proposals and vote on other people\'s suggestions!', + sourceText: "Create proposals and vote on other people's suggestions!", } return await createNotifications(notification) } @@ -106,8 +114,63 @@ export const createNotifications = async (notification: Notification) => { } } -export const createNotification = async (user: Row<'users'>, notification: Notification, pg: SupabaseDirectClient) => { +export const createNotification = async ( + user: Row<'users'>, + notification: Notification, + pg: SupabaseDirectClient +) => { notification.userId = user.id console.log('notification', user.username) return await insertNotificationToSupabase(notification, pg) } + +/** + * Send "Events now available" notification to all users + * Uses the new template-based system for efficient bulk notifications + */ +export const createEventsAvailableNotifications = async () => { + const pg = createSupabaseDirectClient() + + // Fetch all users + const {data: users, error} = await tryCatch( + pg.many>('select id from users') + ) + + if (error) { + console.error('Error fetching users', error) + return {success: false, error} + } + + if (!users || users.length === 0) { + console.error('No users found') + return {success: false, error: 'No users found'} + } + + const userIds = users.map((u) => u.id) + + // Create template and bulk notifications using the new system + const {templateId, count} = await createBulkNotification( + { + sourceType: 'info', + title: 'New Events Page', + sourceText: + 'You can now create and join events on Compass! Meet up with other members online or in person for workshops, social events, etc.', + sourceSlug: '/events', + sourceUserAvatarUrl: + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185', + sourceUpdateType: 'created', + }, + userIds, + pg + ) + + console.log( + `Created events notification template ${templateId} for ${count} users` + ) + + return { + success: true, + templateId, + userCount: count, + } +} diff --git a/backend/api/src/get-notifications.ts b/backend/api/src/get-notifications.ts index d7a889fc..c2c84ce5 100644 --- a/backend/api/src/get-notifications.ts +++ b/backend/api/src/get-notifications.ts @@ -1,6 +1,6 @@ -import { createSupabaseDirectClient } from 'shared/supabase/init' -import { APIHandler } from 'api/helpers/endpoint' -import { Notification } from 'common/notifications' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {APIHandler} from 'api/helpers/endpoint' +import {Notification} from 'common/notifications' export const getNotifications: APIHandler<'get-notifications'> = async ( props, @@ -9,15 +9,44 @@ export const getNotifications: APIHandler<'get-notifications'> = async ( const { limit, after } = props const pg = createSupabaseDirectClient() const query = ` - select data from user_notifications - where user_id = $1 - and ($3 is null or (data->'createdTime')::bigint > $3) - order by (data->'createdTime')::bigint desc + select case + when un.template_id is not null then + jsonb_build_object( + 'id', un.notification_id, + 'userId', un.user_id, + 'templateId', un.template_id, + 'title', nt.title, + 'sourceType', nt.source_type, + 'sourceUpdateType', nt.source_update_type, + 'createdTime', nt.created_time, + 'isSeen', coalesce((un.data ->> 'isSeen')::boolean, false), + 'viewTime', (un.data ->> 'viewTime')::bigint, + 'sourceText', nt.source_text, + 'sourceSlug', nt.source_slug, + 'sourceUserAvatarUrl', nt.source_user_avatar_url, + 'data', nt.data + ) + else + un.data + end as notification_data + from user_notifications un + left join notification_templates nt on un.template_id = nt.id + where un.user_id = $1 + and ($3 is null or + case + when un.template_id is not null then nt.created_time > $3 + else (un.data ->> 'createdTime')::bigint > $3 + end + ) + order by case + when un.template_id is not null then nt.created_time + else (un.data ->> 'createdTime')::bigint + end desc limit $2 ` return await pg.map( query, [auth.uid, limit, after], - (row) => row.data as Notification + (row) => row.notification_data as Notification ) } diff --git a/backend/shared/src/supabase/notifications.ts b/backend/shared/src/supabase/notifications.ts index 75668640..e6618ed6 100644 --- a/backend/shared/src/supabase/notifications.ts +++ b/backend/shared/src/supabase/notifications.ts @@ -1,19 +1,48 @@ -import { Notification } from 'common/notifications' -import { SupabaseDirectClient } from 'shared/supabase/init' -import { broadcast } from 'shared/websockets/server' -import { bulkInsert } from 'shared/supabase/utils' +import {Notification, NotificationTemplate} from 'common/notifications' +import {SupabaseDirectClient} from 'shared/supabase/init' +import {broadcast} from 'shared/websockets/server' +import {bulkInsert} from 'shared/supabase/utils' +/** + * Insert a single notification to a single user. + * For backwards compatibility - stores full notification data. + * Consider using createNotificationTemplate + createUserNotifications for new code. + */ export const insertNotificationToSupabase = async ( notification: Notification, pg: SupabaseDirectClient ) => { - await pg.none( - `insert into postgres.public.user_notifications (user_id, notification_id, data) values ($1, $2, $3) on conflict do nothing`, - [notification.userId, notification.id, notification] - ) + // Check if this notification has a template_id (new style) + if (notification.templateId) { + await pg.none( + `insert into user_notifications (user_id, notification_id, template_id, data) + values ($1, $2, $3, $4) + on conflict do nothing`, + [ + notification.userId, + notification.id, + notification.templateId, + { + isSeen: notification.isSeen, + viewTime: notification.viewTime, + }, + ] + ) + } else { + // Legacy style - store full notification in data + await pg.none( + `insert into user_notifications (user_id, notification_id, data) + values ($1, $2, $3) + on conflict do nothing`, + [notification.userId, notification.id, notification] + ) + } broadcast(`user-notifications/${notification.userId}`, { notification }) } +/** + * Bulk insert notifications - for backwards compatibility + */ export const bulkInsertNotifications = async ( notifications: Notification[], pg: SupabaseDirectClient @@ -31,3 +60,95 @@ export const bulkInsertNotifications = async ( broadcast(`user-notifications/${notification.userId}`, { notification }) ) } + +/** + * Create a notification template - stores shared notification content + * Returns the template ID + */ +export const createNotificationTemplate = async ( + template: NotificationTemplate, + pg: SupabaseDirectClient +): Promise => { + await pg.none( + `insert into notification_templates + (id, source_type, title, source_text, source_slug, source_user_avatar_url, source_update_type, created_time, data) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + on conflict (id) do update set source_type = excluded.source_type, + title = excluded.title, + source_text = excluded.source_text, + source_slug = excluded.source_slug, + source_user_avatar_url = excluded.source_user_avatar_url, + source_update_type = excluded.source_update_type, + data = excluded.data`, + [ + template.id, + template.sourceType, + template.title ?? null, + template.sourceText, + template.sourceSlug ?? null, + template.sourceUserAvatarUrl ?? null, + template.sourceUpdateType ?? null, + template.createdTime, + template.data ?? {}, + ] + ) + return template.id +} + +/** + * Create user notifications that reference a template + * Lightweight - only stores user_id, notification_id, template_id, and user-specific data + */ +export const createUserNotifications = async ( + templateId: string, + userIds: string[], + pg: SupabaseDirectClient, + // baseNotificationId?: string +) => { + const timestamp = Date.now() + const notifications = userIds.map((userId, index) => ({ + user_id: userId, + notification_id: `${templateId}-${userId}-${timestamp}-${index}`, + template_id: templateId, + data: {isSeen: false}, + })) + + await bulkInsert(pg, 'user_notifications', notifications) + + // Broadcast to all users + notifications.forEach((n) => { + broadcast(`user-notifications/${n.user_id}`, { + notification: {templateId, notificationId: n.notification_id}, + }) + }) + + return notifications.length +} + +/** + * Create a bulk notification using the template system + * Creates one template and many lightweight user notification entries + */ +export const createBulkNotification = async ( + template: Omit, + userIds: string[], + pg: SupabaseDirectClient +) => { + const timestamp = Date.now() + const templateId = `${template.sourceType}-${timestamp}` + + // Create the template + await createNotificationTemplate( + { + ...template, + id: templateId, + createdTime: timestamp, + }, + pg + ) + + // Create lightweight user notifications + const count = await createUserNotifications(templateId, userIds, pg) + + return {templateId, count} +} diff --git a/backend/supabase/migrations/20260218_add_notification_templates.sql b/backend/supabase/migrations/20260218_add_notification_templates.sql new file mode 100644 index 00000000..c0a4ee25 --- /dev/null +++ b/backend/supabase/migrations/20260218_add_notification_templates.sql @@ -0,0 +1,47 @@ +-- Create notification_templates table +CREATE TABLE IF NOT EXISTS notification_templates +( + id TEXT PRIMARY KEY NOT NULL, + source_type TEXT NOT NULL, + title TEXT, + source_text TEXT NOT NULL, + source_slug TEXT, + source_user_avatar_url TEXT, + source_update_type TEXT, + created_time BIGINT NOT NULL, + data JSONB DEFAULT '{}'::jsonb +); + +-- Add indexes for common queries +CREATE INDEX IF NOT EXISTS notification_templates_source_type_idx + ON notification_templates (source_type); + +-- Row Level Security +ALTER TABLE notification_templates + ENABLE ROW LEVEL SECURITY; + +-- Policy: Everyone can read notification templates +DROP POLICY IF EXISTS "public read" ON notification_templates; +CREATE POLICY "public read" ON notification_templates + FOR SELECT USING (true); + +-- Update user_notifications table structure +-- Add template_id column and modify data column to only store user-specific data + +-- Add template_id column if it doesn't exist +DO +$$ + BEGIN + IF NOT EXISTS (SELECT 1 + FROM information_schema.columns + WHERE table_name = 'user_notifications' + AND column_name = 'template_id') THEN + ALTER TABLE user_notifications + ADD COLUMN template_id TEXT REFERENCES notification_templates (id) ON DELETE CASCADE; + END IF; + END +$$; + +-- Create index on template_id for faster lookups +CREATE INDEX IF NOT EXISTS user_notifications_template_id_idx + ON user_notifications (template_id); diff --git a/common/src/notifications.ts b/common/src/notifications.ts index 15f81275..e45810fe 100644 --- a/common/src/notifications.ts +++ b/common/src/notifications.ts @@ -1,5 +1,28 @@ -import { Row, SupabaseClient } from 'common/supabase/utils' +import {Row, SupabaseClient} from 'common/supabase/utils' +// Notification template - stores the shared content for notifications sent to multiple users +export type NotificationTemplate = { + id: string + sourceType: string + title?: string + sourceText: string + sourceSlug?: string + sourceUserAvatarUrl?: string + sourceUpdateType?: 'created' | 'updated' | 'deleted' + createdTime: number + data?: { [key: string]: any } +} + +// User-specific notification data (lightweight - references template) +export type UserNotification = { + notificationId: string + userId: string + templateId: string + isSeen: boolean + viewTime?: number +} + +// Full notification (combines template + user data) - for backwards compatibility export type Notification = { id: string userId: string @@ -28,6 +51,9 @@ export type Notification = { sourceTitle?: string isSeenOnHref?: string + + // New field for template-based notifications + templateId?: string } // export const NOTIFICATION_TYPES_TO_SELECT = [ diff --git a/web/components/widgets/linkify.tsx b/web/components/widgets/linkify.tsx index ff47eb8c..3647013e 100644 --- a/web/components/widgets/linkify.tsx +++ b/web/components/widgets/linkify.tsx @@ -1,11 +1,16 @@ import clsx from 'clsx' -import { Fragment } from 'react' +import {Fragment} from 'react' import Link from 'next/link' -import { linkClass } from './site-link' +import {linkClass} from './site-link' // Return a JSX span, linkifying @username, and https://... export function Linkify(props: { text: string; className?: string }) { const { text, className } = props + + // Handle undefined/null text + if (!text) { + return + } // Replace "m1234" with "ϻ1234" // const mRegex = /(\W|^)m(\d+)/g // text = text.replace(mRegex, (_, pre, num) => `${pre}ϻ${num}`) @@ -53,10 +58,7 @@ export function Linkify(props: { text: string; className?: string }) { export const getLinkTarget = (href: string, newTab?: boolean) => { // TODO: make this more robust against domain changes? - if ( - href.startsWith('http') && - !href.startsWith(`https://compassmeet`) - ) + if (href.startsWith('http') && !href.startsWith(`https://compassmeet`)) return '_blank' return newTab ? '_blank' : '_self' }