Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
4 changes: 3 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,11 @@ model CachedBankData {
}

model PushNotification {
userId Int @id
userId Int
endpoint String
subscription String

@@id([userId, endpoint])
@@schema("public")
}

Expand Down
5 changes: 4 additions & 1 deletion public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
27 changes: 27 additions & 0 deletions src/components/Account/DebugInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'next-i18next';
import { api } from '~/utils/api';
import {
AlertDialog,
AlertDialogAction,
Expand All @@ -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<React.PropsWithChildren> = ({ children }) => {
const { t } = useTranslation('common');
const [newVersion, setNewVersion] = React.useState<string | null>(null);
const sendTestPushNotification = api.user.sendTestPushNotification.useMutation();

useEffect(() => {
// Check github releases API for latest version
Expand Down Expand Up @@ -57,6 +60,21 @@ export const DebugInfo: React.FC<React.PropsWithChildren> = ({ 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 (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
Expand Down Expand Up @@ -91,6 +109,15 @@ export const DebugInfo: React.FC<React.PropsWithChildren> = ({ children }) => {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button
variant="secondary"
onClick={() => {
void onSendTestNotification();
}}
loading={sendTestPushNotification.isPending}
>
{t('account.debug_info_details.send_test_notification')}
</Button>
<AlertDialogCancel>{t('actions.close')}</AlertDialogCancel>
<AlertDialogAction onClick={copyToClipboard}>{t('actions.copy')}</AlertDialogAction>
</AlertDialogFooter>
Expand Down
4 changes: 3 additions & 1 deletion src/components/Account/SubscribeNotification.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Bell, BellOff, ChevronRight } from 'lucide-react';

Check warning on line 1 in src/components/Account/SubscribeNotification.tsx

View workflow job for this annotation

GitHub Actions / check

eslint(no-unused-vars)

Identifier 'ChevronRight' is imported but never used.
import React, { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useAppStore } from '~/store/appStore';
import { api } from '~/utils/api';
import { useTranslation } from 'next-i18next';

import { Button } from '../ui/button';

Check warning on line 8 in src/components/Account/SubscribeNotification.tsx

View workflow job for this annotation

GitHub Actions / check

eslint(no-unused-vars)

Identifier 'Button' is imported but never used.
import { AccountButton } from './AccountButton';

const base64ToUint8Array = (base64: string) => {
Expand All @@ -24,12 +24,13 @@
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
Expand Down Expand Up @@ -81,6 +82,7 @@
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);
}
Expand Down
52 changes: 50 additions & 2 deletions src/server/api/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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 }) => {
Expand Down
93 changes: 71 additions & 22 deletions src/server/api/services/notificationService.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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 ?? '';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ?? '';

Expand All @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions src/server/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading