Implement notification template system and bulk notification creation

This commit is contained in:
MartinBraquet
2026-02-18 22:42:02 +01:00
parent bbd395b904
commit d583dbb945
7 changed files with 331 additions and 43 deletions

View File

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

View File

@@ -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 Playregistered 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 Playregistered 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<Row<'users'>>('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,
}
}

View File

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

View File

@@ -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<string> => {
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<NotificationTemplate, 'id' | 'createdTime'>,
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}
}

View File

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

View File

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

View File

@@ -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 <span className={clsx(className, 'break-anywhere')}></span>
}
// 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'
}