From e253ec5a6bff7f0ba821e05620a04d072e8bfcb1 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 12 Apr 2026 13:07:37 +0200 Subject: [PATCH 1/2] Adjust schema to use a composite id --- .../migration.sql | 21 +++++++++++++++++++ prisma/schema.prisma | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260412120000_push_notification_multi_device/migration.sql diff --git a/prisma/migrations/20260412120000_push_notification_multi_device/migration.sql b/prisma/migrations/20260412120000_push_notification_multi_device/migration.sql new file mode 100644 index 00000000..7f3d3df7 --- /dev/null +++ b/prisma/migrations/20260412120000_push_notification_multi_device/migration.sql @@ -0,0 +1,21 @@ +-- Add endpoint column first so we can backfill values from subscription JSON. +ALTER TABLE "public"."PushNotification" +ADD COLUMN "endpoint" TEXT; + +-- Backfill endpoint from the serialized PushSubscription payload. +UPDATE "public"."PushNotification" +SET "endpoint" = "subscription"::jsonb ->> 'endpoint'; + +-- Drop rows with malformed legacy payloads that don't include endpoint. +DELETE FROM "public"."PushNotification" +WHERE "endpoint" IS NULL OR '' = "endpoint"; + +-- Replace single-device PK with multi-device composite PK. +ALTER TABLE "public"."PushNotification" +DROP CONSTRAINT "PushNotification_pkey"; + +ALTER TABLE "public"."PushNotification" +ALTER COLUMN "endpoint" SET NOT NULL; + +ALTER TABLE "public"."PushNotification" +ADD CONSTRAINT "PushNotification_pkey" PRIMARY KEY ("userId", "endpoint"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 03eee3c6..7f51cdd5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -261,9 +261,11 @@ model CachedBankData { } model PushNotification { - userId Int @id + userId Int + endpoint String subscription String + @@id([userId, endpoint]) @@schema("public") } From 0d80544c5afde5e3e21d88c286a61599d3d47558 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 12 Apr 2026 13:13:14 +0200 Subject: [PATCH 2/2] Implement endpoint based notifications --- public/locales/en/common.json | 5 +- src/components/Account/DebugInfo.tsx | 27 ++++++ .../Account/SubscribeNotification.tsx | 4 +- src/server/api/routers/user.ts | 52 ++++++++++- .../api/services/notificationService.ts | 93 ++++++++++++++----- src/server/notification.ts | 10 ++ 6 files changed, 165 insertions(+), 26 deletions(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 1c88ac37..9399896f 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -68,7 +68,10 @@ "git": "Git", "version": "Version", "new_version_available": "A new version is available:", - "copy_failed": "Failed to copy debug info to clipboard" + "copy_failed": "Failed to copy debug info to clipboard", + "send_test_notification": "Send test notification", + "test_notification_sent": "Test notification sent", + "test_notification_failed": "Could not send test notification" } }, "actions": { diff --git a/src/components/Account/DebugInfo.tsx b/src/components/Account/DebugInfo.tsx index 7c5ab844..6f097d27 100644 --- a/src/components/Account/DebugInfo.tsx +++ b/src/components/Account/DebugInfo.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect } from 'react'; import { useTranslation } from 'next-i18next'; +import { api } from '~/utils/api'; import { AlertDialog, AlertDialogAction, @@ -14,10 +15,12 @@ import { import { toast } from 'sonner'; import { env } from '~/env'; import { cn } from '~/lib/utils'; +import { Button } from '../ui/button'; export const DebugInfo: React.FC = ({ children }) => { const { t } = useTranslation('common'); const [newVersion, setNewVersion] = React.useState(null); + const sendTestPushNotification = api.user.sendTestPushNotification.useMutation(); useEffect(() => { // Check github releases API for latest version @@ -57,6 +60,21 @@ export const DebugInfo: React.FC = ({ children }) => { } }, [t]); + const onSendTestNotification = useCallback(async () => { + try { + const result = await sendTestPushNotification.mutateAsync(); + if (0 === result.sentCount) { + toast.error(t('account.debug_info_details.test_notification_failed')); + return; + } + + toast.success(t('account.debug_info_details.test_notification_sent')); + } catch (error) { + toast.error(t('account.debug_info_details.test_notification_failed')); + console.error('Failed to send test push notification:', error); + } + }, [sendTestPushNotification, t]); + return ( {children} @@ -91,6 +109,15 @@ export const DebugInfo: React.FC = ({ children }) => { + {t('actions.close')} {t('actions.copy')} diff --git a/src/components/Account/SubscribeNotification.tsx b/src/components/Account/SubscribeNotification.tsx index 5f9a19ea..f7fd22cf 100644 --- a/src/components/Account/SubscribeNotification.tsx +++ b/src/components/Account/SubscribeNotification.tsx @@ -24,12 +24,13 @@ const base64ToUint8Array = (base64: string) => { export const SubscribeNotification: React.FC = () => { const { t } = useTranslation(); const updatePushSubscription = api.user.updatePushNotification.useMutation(); + const deletePushSubscription = api.user.deletePushNotification.useMutation(); const [isSubscribed, setIsSubscribed] = useState(false); const webPushPublicKey = useAppStore((s) => s.webPushPublicKey); useEffect(() => { if ('undefined' !== typeof window && 'serviceWorker' in navigator) { - // run only in browser + // Run only in browser navigator.serviceWorker.ready .then((reg) => { reg.pushManager @@ -81,6 +82,7 @@ export const SubscribeNotification: React.FC = () => { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); if (sub) { + await deletePushSubscription.mutateAsync({ subscription: JSON.stringify(sub) }); await sub.unsubscribe(); setIsSubscribed(false); } diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index e47263a7..f56610e0 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -14,7 +14,10 @@ import { db } from '~/server/db'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types'; -// Import { sendExpensePushNotification } from '../services/notificationService'; +import { + getSubscriptionEndpoint, + sendPushNotificationToUsers, +} from '../services/notificationService'; import { getCompleteFriendsDetails, getCompleteGroupDetails, @@ -295,12 +298,25 @@ export const userRouter = createTRPCRouter({ updatePushNotification: protectedProcedure .input(z.object({ subscription: z.string() })) .mutation(async ({ input, ctx }) => { + const endpoint = getSubscriptionEndpoint(input.subscription); + + if (!endpoint) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid push subscription payload', + }); + } + await db.pushNotification.upsert({ where: { - userId: ctx.session.user.id, + userId_endpoint: { + userId: ctx.session.user.id, + endpoint, + }, }, create: { userId: ctx.session.user.id, + endpoint, subscription: input.subscription, }, update: { @@ -309,6 +325,38 @@ export const userRouter = createTRPCRouter({ }); }), + deletePushNotification: protectedProcedure + .input(z.object({ subscription: z.string() })) + .mutation(async ({ input, ctx }) => { + const endpoint = getSubscriptionEndpoint(input.subscription); + if (!endpoint) { + return; + } + + await db.pushNotification + .delete({ + where: { + userId_endpoint: { + userId: ctx.session.user.id, + endpoint, + }, + }, + }) + .catch(() => null); + }), + + sendTestPushNotification: protectedProcedure.mutation(async ({ ctx }) => { + const { sentCount } = await sendPushNotificationToUsers([ctx.session.user.id], { + title: 'SplitPro', + message: 'Test notification from debug info', + data: { + url: '/account', + }, + }); + + return { sentCount }; + }), + deleteFriend: protectedProcedure .input(z.object({ friendId: z.number() })) .mutation(async ({ input, ctx }) => { diff --git a/src/server/api/services/notificationService.ts b/src/server/api/services/notificationService.ts index a6ecd6e1..b7a2f2d4 100644 --- a/src/server/api/services/notificationService.ts +++ b/src/server/api/services/notificationService.ts @@ -1,10 +1,73 @@ import { SplitType } from '@prisma/client'; import { isCurrencyCode } from '~/lib/currency'; +import { type PushMessage } from '~/types'; import { db } from '~/server/db'; import { pushNotification } from '~/server/notification'; import { getCurrencyHelpers } from '~/utils/numbers'; +export const getSubscriptionEndpoint = (subscription: string) => { + try { + const parsed = JSON.parse(subscription) as { endpoint?: string }; + if ('string' === typeof parsed.endpoint && '' !== parsed.endpoint) { + return parsed.endpoint; + } + } catch { + return null; + } + + return null; +}; + +const removeStalePushSubscriptions = async ( + subscriptions: { userId: number; endpoint: string }[], +) => { + if (0 === subscriptions.length) { + return; + } + + await db.pushNotification.deleteMany({ + where: { + OR: subscriptions.map((subscription) => ({ + userId: subscription.userId, + endpoint: subscription.endpoint, + })), + }, + }); +}; + +const isPermanentPushFailure = (statusCode: number | undefined) => + 404 === statusCode || 410 === statusCode; + +export const sendPushNotificationToUsers = async (userIds: number[], pushData: PushMessage) => { + if (0 === userIds.length) { + return { sentCount: 0 }; + } + + const subscriptions = await db.pushNotification.findMany({ + where: { + userId: { + in: userIds, + }, + }, + }); + + const pushResults = await Promise.all( + subscriptions.map(async (s) => { + const result = await pushNotification(s.subscription, pushData); + return { ...result, userId: s.userId, endpoint: s.endpoint }; + }), + ); + + await removeStalePushSubscriptions( + pushResults + .filter((result) => !result.ok && isPermanentPushFailure(result.statusCode)) + .map((result) => ({ userId: result.userId, endpoint: result.endpoint })), + ); + + return { sentCount: pushResults.filter((result) => result.ok).length }; +}; + export async function sendExpensePushNotification(expenseId: string) { const expense = await db.expense.findUnique({ where: { @@ -69,14 +132,6 @@ export async function sendExpensePushNotification(expenseId: string) { ({ userId, amount }) => userId !== expense.addedBy && 0n !== amount, ); - const subscriptions = await db.pushNotification.findMany({ - where: { - userId: { - in: participants.map((p) => p.userId), - }, - }, - }); - // A way to localize it and reuse our utils would be ideal const getUserDisplayName = (user: { name: string | null; email: string | null } | null) => user?.name ?? user?.email ?? ''; @@ -140,9 +195,10 @@ export async function sendExpensePushNotification(expenseId: string) { }, }; - const pushNotifications = subscriptions.map((s) => pushNotification(s.subscription, pushData)); - - await Promise.all(pushNotifications); + await sendPushNotificationToUsers( + participants.map((p) => p.userId), + pushData, + ); } export async function sendGroupSimplifyDebtsToggleNotification( @@ -196,14 +252,6 @@ export async function sendGroupSimplifyDebtsToggleNotification( return; } - const subscriptions = await db.pushNotification.findMany({ - where: { - userId: { - in: recipients.map((r) => r.userId), - }, - }, - }); - const getUserDisplayName = (user: { name: string | null; email: string | null } | null) => user?.name ?? user?.email ?? ''; @@ -218,9 +266,10 @@ export async function sendGroupSimplifyDebtsToggleNotification( }, }; - const pushNotifications = subscriptions.map((s) => pushNotification(s.subscription, pushData)); - - await Promise.all(pushNotifications); + await sendPushNotificationToUsers( + recipients.map((r) => r.userId), + pushData, + ); } catch (error) { console.error('Error sending group simplify debts toggle notifications', error); } diff --git a/src/server/notification.ts b/src/server/notification.ts index 2f44a629..571517c1 100644 --- a/src/server/notification.ts +++ b/src/server/notification.ts @@ -17,7 +17,17 @@ export async function pushNotification(subscription: string, message: PushMessag const _subscription = JSON.parse(subscription) as webpush.PushSubscription; const response = await webpush.sendNotification(_subscription, JSON.stringify(message)); console.log('Push notification response', response); + return { ok: true } as const; } catch (error) { console.error('Error sending push notification', error); + const statusCode = + 'object' === typeof error && + null !== error && + 'statusCode' in error && + 'number' === typeof error.statusCode + ? error.statusCode + : undefined; + + return { ok: false, statusCode } as const; } }