mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-23 18:36:02 -05:00
Implement notification template system and bulk notification creation
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user