From 13540ff8e0b72b29c4ef981309f8d8de293b90cb Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 00:25:48 +0530 Subject: [PATCH 01/14] fix: propagate sendInviteEmail's send result instead of discarding it sendInviteEmail awaited sendMail without returning its result, so callers always saw undefined instead of whether the send succeeded. --- src/server/mailer.ts | 4 +- src/tests/mailer.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/tests/mailer.test.ts diff --git a/src/server/mailer.ts b/src/server/mailer.ts index 15c601cfc..a9176e208 100644 --- a/src/server/mailer.ts +++ b/src/server/mailer.ts @@ -61,14 +61,14 @@ export async function sendInviteEmail(email: string, name: string) { if ('development' === env.NODE_ENV) { console.log('Sending invite email', email, name); - return; + return true; } const subject = 'Invitation to SplitPro'; const text = `Hey,\n\nYou have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:\n${env.NEXTAUTH_URL}\n\nThanks,\nSplitPro Team`; const html = `

Hey,

You have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:

Sign in to ${host}


Thanks,
SplitPro Team

`; - await sendMail(email, subject, text, html); + return await sendMail(email, subject, text, html); } export async function sendFeedbackEmail(feedback: string, user: User) { diff --git a/src/tests/mailer.test.ts b/src/tests/mailer.test.ts new file mode 100644 index 000000000..5f54a2b49 --- /dev/null +++ b/src/tests/mailer.test.ts @@ -0,0 +1,82 @@ +import nodemailer from 'nodemailer'; + +import { env } from '~/env'; +import { sendToDiscord } from '~/server/service-notification'; +import { sendInviteEmail } from '~/server/mailer'; + +jest.mock('~/env', () => ({ + env: { + NODE_ENV: 'production', + EMAIL_SERVER_HOST: 'smtp.example.com', + EMAIL_SERVER_PORT: '587', + EMAIL_SERVER_USER: 'user', + EMAIL_SERVER_PASSWORD: 'pass', + EMAIL_TLS_REJECT_UNAUTHORIZED: true, + FROM_EMAIL: 'noreply@example.com', + NEXTAUTH_URL: 'https://splitpro.example.com', + ENABLE_SENDING_INVITES: true, + }, +})); + +jest.mock('nodemailer', () => { + const sendMail = jest.fn(); + return { + __esModule: true, + default: { createTransport: jest.fn(() => ({ sendMail })) }, + }; +}); + +jest.mock('~/server/service-notification', () => ({ + sendToDiscord: jest.fn(), +})); + +const mockCreateTransport = jest.mocked(nodemailer.createTransport); +const mockSendMail = jest.mocked( + mockCreateTransport({} as Parameters[0]).sendMail, +); +const mockSendToDiscord = jest.mocked(sendToDiscord); + +describe('sendInviteEmail', () => { + beforeEach(() => { + jest.clearAllMocks(); + (env as { NODE_ENV: string }).NODE_ENV = 'production'; + (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = true; + }); + + it('resolves true when the email actually sends', async () => { + mockSendMail.mockResolvedValue({ + messageId: 'test-message-id', + envelope: { from: 'noreply@example.com', to: ['friend@example.com'] }, + accepted: ['friend@example.com'], + rejected: [], + pending: [], + response: '250 OK', + }); + + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true); + expect(mockSendMail).toHaveBeenCalledTimes(1); + }); + + it('resolves false when the SMTP transport fails', async () => { + mockSendMail.mockRejectedValue(new Error('connect ECONNREFUSED')); + + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(false); + expect(mockSendToDiscord).toHaveBeenCalledTimes(1); + }); + + it('skips sending in development mode and resolves true', async () => { + (env as { NODE_ENV: string }).NODE_ENV = 'development'; + + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true); + expect(mockCreateTransport).not.toHaveBeenCalled(); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it('still throws when invites are disabled', async () => { + (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = false; + + await expect(sendInviteEmail('friend@example.com', 'Alice')).rejects.toThrow( + 'Sending invites is not enabled', + ); + }); +}); From 1203c10722658cfe0ffcf63599fb3ddaefddb9da Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 00:26:59 +0530 Subject: [PATCH 02/14] fix: surface invite-email send failures from inviteFriend mutation Await sendInviteEmail (previously fire-and-forget) and throw a TRPCError when it fails, on both the create and found-friend paths, so a retry after a failed invite doesn't silently no-op. --- src/server/api/routers/user.ts | 39 +++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 1b7e9a87f..08d6e3b83 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -64,21 +64,36 @@ export const userRouter = createTRPCRouter({ }, }); - if (friend) { - return friend; - } - - const user = await db.user.create({ - data: { - email: input.email, - name: input.email.split('@')[0], - }, - }); + const user = + friend ?? + (await db.user.create({ + data: { + email: input.email, + name: input.email.split('@')[0], + }, + })); if (input.sendInviteEmail) { - sendInviteEmail(input.email, session.user.name ?? session.user.email ?? '').catch((err) => { + let sent = false; + try { + sent = await sendInviteEmail(input.email, session.user.name ?? session.user.email ?? ''); + } catch (err) { console.error('Error sending invite email', err); - }); + const disabled = err instanceof Error && 'Sending invites is not enabled' === err.message; + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: disabled + ? 'Invite emails are disabled on this server.' + : 'Failed to send invite email. Check your SMTP configuration.', + }); + } + + if (!sent) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to send invite email. Check your SMTP configuration.', + }); + } } return user; From 5959eb8e6cf0b9bc33352215452c6136158fb1e7 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 00:30:23 +0530 Subject: [PATCH 03/14] fix: show toast and reconcile UI state on invite-email failure Add onError handlers to the invite mutation in AddMembers.tsx and SelectUserOrGroup.tsx so a failed invite email surfaces a toast instead of failing silently, and clean up the optimistic placeholder participant so it doesn't get stranded. --- public/locales/en/common.json | 3 ++- src/components/AddExpense/SelectUserOrGroup.tsx | 6 ++++++ src/components/group/AddMembers.tsx | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 5ec3f196f..7b0717bca 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -162,7 +162,8 @@ "invalid_cron_expression": "Invalid cron expression", "currency_conversion_failed": "Failed to convert currencies", "recurrence_delete_failed": "Failed to delete recurrence", - "recurrence_update_failed": "Failed to update recurrence" + "recurrence_update_failed": "Failed to update recurrence", + "invite_email_failed": "Failed to send invite email. Check the SMTP configuration." }, "bank_transactions": { "choose_bank_provider": "Choose bank provider", diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index 85ef2265b..52dbe44ed 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -5,6 +5,7 @@ import { SendIcon } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import Image from 'next/image'; import React, { useCallback } from 'react'; +import { toast } from 'sonner'; import { z } from 'zod'; import { useAddExpenseStore } from '~/store/addStore'; @@ -56,6 +57,10 @@ export const SelectUserOrGroup: React.FC<{ addOrUpdateParticipant(user); setNameOrEmail(''); }, + onError: () => { + removeParticipant(-1); + toast.error(t('errors.invite_email_failed')); + }, }, ); addOrUpdateParticipant({ @@ -81,6 +86,7 @@ export const SelectUserOrGroup: React.FC<{ addOrUpdateParticipant, setNameOrEmail, removeParticipant, + t, ], ); diff --git a/src/components/group/AddMembers.tsx b/src/components/group/AddMembers.tsx index 0a101537d..41ddb8ecb 100644 --- a/src/components/group/AddMembers.tsx +++ b/src/components/group/AddMembers.tsx @@ -93,6 +93,10 @@ const AddMembers: React.FC<{ onSuccess: (user) => { onSave({ ...userIds, [user.id]: true }); }, + onError: () => { + toast.error(t('errors.invite_email_failed')); + friendsQuery.refetch().catch(console.error); + }, }, ); } From ddf689038cb218f978fafcb6bf8bede1733b507d Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 00:59:35 +0530 Subject: [PATCH 04/14] fix(review): apply review findings - Add onError to the AddMembers.tsx retry mutation so a second failure isn't silently swallowed (correctness, testing, reliability, adversarial reviewers). - Skip re-sending invite emails to friends who already have a verified account, so inviting an arbitrary existing user no longer triggers an unwanted email (api-contract, adversarial reviewers). - Simplify inviteFriend's disabled-invites check to a direct env.ENABLE_SENDING_INVITES check instead of string-matching an error message (code-quality/reuse reviewers). - Fix mailer.test.ts's mocking-pattern citation to point at the repo's actual jest.mock precedent (coherence reviewer). --- src/components/group/AddMembers.tsx | 12 +++++++++++- src/server/api/routers/user.ts | 20 ++++++++++---------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/group/AddMembers.tsx b/src/components/group/AddMembers.tsx index 41ddb8ecb..d74ed856f 100644 --- a/src/components/group/AddMembers.tsx +++ b/src/components/group/AddMembers.tsx @@ -95,7 +95,17 @@ const AddMembers: React.FC<{ }, onError: () => { toast.error(t('errors.invite_email_failed')); - friendsQuery.refetch().catch(console.error); + // The friend row was still created despite the email failing (see #722); + // Re-fetch it without retrying the email so it can still be added to the group. + addFriendMutation.mutate( + { email: inputValue.toLowerCase(), sendInviteEmail: false }, + { + onSuccess: (user) => { + onSave({ ...userIds, [user.id]: true }); + }, + onError: () => toast.error(t('errors.add_member_failed')), + }, + ); }, }, ); diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 08d6e3b83..845e5e4fb 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -73,22 +73,22 @@ export const userRouter = createTRPCRouter({ }, })); - if (input.sendInviteEmail) { - let sent = false; - try { - sent = await sendInviteEmail(input.email, session.user.name ?? session.user.email ?? ''); - } catch (err) { - console.error('Error sending invite email', err); - const disabled = err instanceof Error && 'Sending invites is not enabled' === err.message; + // Only a just-created or not-yet-verified user should receive an invite + // Email -- skip re-sending to a friend who already has a verified account. + if (input.sendInviteEmail && !friend?.emailVerified) { + if (!env.ENABLE_SENDING_INVITES) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: disabled - ? 'Invite emails are disabled on this server.' - : 'Failed to send invite email. Check your SMTP configuration.', + message: 'Invite emails are disabled on this server.', }); } + const sent = await sendInviteEmail( + input.email, + session.user.name ?? session.user.email ?? '', + ); if (!sent) { + console.error('Error sending invite email to', input.email); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send invite email. Check your SMTP configuration.', From 501dc85725ebdf024578c1abbe85db9e8a441880 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 01:00:08 +0530 Subject: [PATCH 05/14] chore: fix comment wording mangled by pre-commit autofix --- src/server/api/routers/user.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 845e5e4fb..e8fda0553 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -73,8 +73,7 @@ export const userRouter = createTRPCRouter({ }, })); - // Only a just-created or not-yet-verified user should receive an invite - // Email -- skip re-sending to a friend who already has a verified account. + // Only a just-created or not-yet-verified friend should get an invite email. if (input.sendInviteEmail && !friend?.emailVerified) { if (!env.ENABLE_SENDING_INVITES) { throw new TRPCError({ From f8bef61d182db7372411cdd75a5979114a8b0067 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 08:07:28 +0530 Subject: [PATCH 06/14] fix: add machine-readable AppError cause for tRPC error discrimination Mirrors the existing zodError pattern in errorFormatter so callers can branch on a specific failure cause instead of message text. --- src/server/api/appError.test.ts | 23 +++++++++++++++++++++++ src/server/api/appError.ts | 13 +++++++++++++ src/server/api/trpc.ts | 4 +++- 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/server/api/appError.test.ts create mode 100644 src/server/api/appError.ts diff --git a/src/server/api/appError.test.ts b/src/server/api/appError.test.ts new file mode 100644 index 000000000..4dfc95c2b --- /dev/null +++ b/src/server/api/appError.test.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { AppError, getAppErrorCode } from '~/server/api/appError'; + +describe('getAppErrorCode', () => { + it('returns the code for an AppError cause', () => { + expect(getAppErrorCode(new AppError('SOME_CODE', 'failed'))).toBe('SOME_CODE'); + }); + + it('returns null for undefined', () => { + expect(getAppErrorCode(undefined)).toBeNull(); + }); + + it('returns null for a plain Error', () => { + expect(getAppErrorCode(new Error('boom'))).toBeNull(); + }); + + it('returns null for a ZodError', () => { + const result = z.string().safeParse(123); + expect(result.success).toBe(false); + expect(getAppErrorCode(!result.success ? result.error : undefined)).toBeNull(); + }); +}); diff --git a/src/server/api/appError.ts b/src/server/api/appError.ts new file mode 100644 index 000000000..8369444da --- /dev/null +++ b/src/server/api/appError.ts @@ -0,0 +1,13 @@ +export class AppError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'AppError'; + this.code = code; + } +} + +export function getAppErrorCode(cause: unknown): string | null { + return cause instanceof AppError ? cause.code : null; +} diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index fa6fdbc1a..9ff2189ae 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -13,6 +13,7 @@ import { type Session } from 'next-auth'; import superjson from 'superjson'; import { ZodError, z } from 'zod'; +import { getAppErrorCode } from '~/server/api/appError'; import { getServerAuthSession } from '~/server/auth'; import { db } from '~/server/db'; @@ -76,6 +77,7 @@ const t = initTRPC.context().create({ data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + appErrorCode: getAppErrorCode(error.cause), }, }; }, @@ -119,7 +121,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { return next({ ctx: { - // infers the `session` as non-nullable + // Infers the `session` as non-nullable session: { ...ctx.session, user: ctx.session.user }, }, }); From d60a7c7b236d3b1a2d9dee94eb52daad62d1ea91 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 08:07:36 +0530 Subject: [PATCH 07/14] fix: escape inviter name in invite-email HTML to prevent HTML injection The user-controlled inviter name was interpolated unescaped into the invite email's HTML body, letting an inviter inject markup into a recipient's inbox. --- src/server/mailer.ts | 11 +++++- src/tests/mailer.test.ts | 77 +++++++++++++++++++++++++++------------- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/server/mailer.ts b/src/server/mailer.ts index a9176e208..15d2623d2 100644 --- a/src/server/mailer.ts +++ b/src/server/mailer.ts @@ -8,6 +8,15 @@ import { sendToDiscord } from './service-notification'; // oxlint-disable-next-line init-declarations let transporter: Transporter; +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + export const mailServerConfig = { host: env.EMAIL_SERVER_HOST, port: parseInt(env.EMAIL_SERVER_PORT ?? ''), @@ -66,7 +75,7 @@ export async function sendInviteEmail(email: string, name: string) { const subject = 'Invitation to SplitPro'; const text = `Hey,\n\nYou have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:\n${env.NEXTAUTH_URL}\n\nThanks,\nSplitPro Team`; - const html = `

Hey,

You have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:

Sign in to ${host}


Thanks,
SplitPro Team

`; + const html = `

Hey,

You have been invited to SplitPro by ${escapeHtml(name)}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:

Sign in to ${host}


Thanks,
SplitPro Team

`; return await sendMail(email, subject, text, html); } diff --git a/src/tests/mailer.test.ts b/src/tests/mailer.test.ts index 5f54a2b49..4425a2830 100644 --- a/src/tests/mailer.test.ts +++ b/src/tests/mailer.test.ts @@ -36,6 +36,15 @@ const mockSendMail = jest.mocked( ); const mockSendToDiscord = jest.mocked(sendToDiscord); +const mockSentMessageInfo = { + messageId: 'test-message-id', + envelope: { from: 'noreply@example.com', to: ['friend@example.com'] }, + accepted: ['friend@example.com'], + rejected: [], + pending: [], + response: '250 OK', +}; + describe('sendInviteEmail', () => { beforeEach(() => { jest.clearAllMocks(); @@ -43,40 +52,60 @@ describe('sendInviteEmail', () => { (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = true; }); - it('resolves true when the email actually sends', async () => { - mockSendMail.mockResolvedValue({ - messageId: 'test-message-id', - envelope: { from: 'noreply@example.com', to: ['friend@example.com'] }, - accepted: ['friend@example.com'], - rejected: [], - pending: [], - response: '250 OK', + describe('when the send succeeds', () => { + it('resolves true when the email actually sends', async () => { + mockSendMail.mockResolvedValue(mockSentMessageInfo); + + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true); + expect(mockSendMail).toHaveBeenCalledTimes(1); + }); + + it('escapes HTML special characters in the inviter name', async () => { + mockSendMail.mockResolvedValue(mockSentMessageInfo); + + await sendInviteEmail('friend@example.com', ''); + + const sentHtml = mockSendMail.mock.calls[0]?.[0]?.html; + expect(sentHtml).toEqual(expect.stringContaining('<script>')); + expect(sentHtml).not.toEqual(expect.stringContaining('')); }); - await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true); - expect(mockSendMail).toHaveBeenCalledTimes(1); + it('does not alter or double-escape a plain alphanumeric name', async () => { + mockSendMail.mockResolvedValue(mockSentMessageInfo); + + await sendInviteEmail('friend@example.com', 'Alice Smith'); + + const sentHtml = mockSendMail.mock.calls[0]?.[0]?.html; + expect(sentHtml).toEqual(expect.stringContaining('Alice Smith')); + }); }); - it('resolves false when the SMTP transport fails', async () => { - mockSendMail.mockRejectedValue(new Error('connect ECONNREFUSED')); + describe('when the send fails', () => { + it('resolves false when the SMTP transport fails', async () => { + mockSendMail.mockRejectedValue(new Error('connect ECONNREFUSED')); - await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(false); - expect(mockSendToDiscord).toHaveBeenCalledTimes(1); + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(false); + expect(mockSendToDiscord).toHaveBeenCalledTimes(1); + }); }); - it('skips sending in development mode and resolves true', async () => { - (env as { NODE_ENV: string }).NODE_ENV = 'development'; + describe('in development mode', () => { + it('skips sending and resolves true', async () => { + (env as { NODE_ENV: string }).NODE_ENV = 'development'; - await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true); - expect(mockCreateTransport).not.toHaveBeenCalled(); - expect(mockSendMail).not.toHaveBeenCalled(); + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true); + expect(mockCreateTransport).not.toHaveBeenCalled(); + expect(mockSendMail).not.toHaveBeenCalled(); + }); }); - it('still throws when invites are disabled', async () => { - (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = false; + describe('when invites are disabled', () => { + it('still throws', async () => { + (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = false; - await expect(sendInviteEmail('friend@example.com', 'Alice')).rejects.toThrow( - 'Sending invites is not enabled', - ); + await expect(sendInviteEmail('friend@example.com', 'Alice')).rejects.toThrow( + 'Sending invites is not enabled', + ); + }); }); }); From 121c948c675263549d9a373314c7faecfb1d6b48 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 08:07:45 +0530 Subject: [PATCH 08/14] fix: rate-limit invite-email sends and stop logging raw recipient emails Add a per-target lastInvitedAt cooldown (atomic claim via a single conditional update, so concurrent requests can't both pass a read-then-write check) so repeated inviteFriend calls can't send unlimited SMTP messages to the same target. Also stop logging the raw recipient email address on send failure; log the user id instead. --- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + src/server/api/routers/user.ts | 34 +++++++++++++++++-- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260906080000_add_user_last_invited_at/migration.sql diff --git a/prisma/migrations/20260906080000_add_user_last_invited_at/migration.sql b/prisma/migrations/20260906080000_add_user_last_invited_at/migration.sql new file mode 100644 index 000000000..474a9cba2 --- /dev/null +++ b/prisma/migrations/20260906080000_add_user_last_invited_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastInvitedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 576ece269..dbe43f53c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { name String? email String? @unique emailVerified DateTime? + lastInvitedAt DateTime? image String? currency String @default("USD") defaultCurrency String? diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index e8fda0553..e003b924d 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -9,6 +9,7 @@ import { toSortedFriendPair, } from '~/lib/defaultSplit'; import { simplifyDebts } from '~/lib/simplify'; +import { AppError } from '~/server/api/appError'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; @@ -25,6 +26,8 @@ import { importUserBalanceFromSplitWise, } from '../services/splitService'; +const INVITE_COOLDOWN_MS = 60_000; + export const userRouter = createTRPCRouter({ me: protectedProcedure.query(({ ctx }) => ctx.session.user), @@ -77,8 +80,31 @@ export const userRouter = createTRPCRouter({ if (input.sendInviteEmail && !friend?.emailVerified) { if (!env.ENABLE_SENDING_INVITES) { throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', + code: 'PRECONDITION_FAILED', message: 'Invite emails are disabled on this server.', + cause: new AppError('INVITES_DISABLED', 'Invite emails are disabled on this server.'), + }); + } + + // Claim the cooldown atomically, so concurrent requests can't both pass a read-then-write check. + const claim = await db.user.updateMany({ + where: { + id: user.id, + OR: [ + { lastInvitedAt: null }, + { lastInvitedAt: { lt: new Date(Date.now() - INVITE_COOLDOWN_MS) } }, + ], + }, + data: { lastInvitedAt: new Date() }, + }); + if (0 === claim.count) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: 'Please wait before re-sending an invite to this address.', + cause: new AppError( + 'INVITE_RATE_LIMITED', + 'Please wait before re-sending an invite to this address.', + ), }); } @@ -87,10 +113,14 @@ export const userRouter = createTRPCRouter({ session.user.name ?? session.user.email ?? '', ); if (!sent) { - console.error('Error sending invite email to', input.email); + console.error('Error sending invite email to user', user.id); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send invite email. Check your SMTP configuration.', + cause: new AppError( + 'INVITE_EMAIL_SEND_FAILED', + 'Failed to send invite email. Check your SMTP configuration.', + ), }); } } From 70e58a6ad9ae8dfb012de3c3373c8a31c9baac02 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 08:08:11 +0530 Subject: [PATCH 09/14] fix: discriminate genuine send failures from other invite errors, client-side Both onError handlers now check the server's appErrorCode instead of showing the SMTP-specific toast for any inviteFriend failure. AddMembers.tsx also captures the submitted email once so an in-flight mutation can't be retargeted by a later edit to the input field. Includes a one-line comment-capitalization fix in trpc.ts picked up by the pre-commit lint-staged hook while formatting the batch. --- .../AddExpense/SelectUserOrGroup.tsx | 9 +++-- src/components/AddExpense/UserInput.tsx | 1 + src/components/group/AddMembers.tsx | 35 ++++++++++++------- src/pages/add.tsx | 1 + src/pages/balances/[friendId].tsx | 1 + src/tests/addStore.test.ts | 1 + 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index 52dbe44ed..e8242a035 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -57,9 +57,13 @@ export const SelectUserOrGroup: React.FC<{ addOrUpdateParticipant(user); setNameOrEmail(''); }, - onError: () => { + onError: (err) => { removeParticipant(-1); - toast.error(t('errors.invite_email_failed')); + toast.error( + 'INVITE_EMAIL_SEND_FAILED' === err.data?.appErrorCode + ? t('errors.invite_email_failed') + : t('errors.add_member_failed'), + ); }, }, ); @@ -68,6 +72,7 @@ export const SelectUserOrGroup: React.FC<{ name: nameOrEmail, email: nameOrEmail, emailVerified: new Date(), + lastInvitedAt: null, image: null, currency: 'USD', defaultCurrency: null, diff --git a/src/components/AddExpense/UserInput.tsx b/src/components/AddExpense/UserInput.tsx index e200c7c3b..8bc654e65 100644 --- a/src/components/AddExpense/UserInput.tsx +++ b/src/components/AddExpense/UserInput.tsx @@ -60,6 +60,7 @@ export const UserInput: React.FC<{ name: nameOrEmail, email: nameOrEmail, emailVerified: new Date(), + lastInvitedAt: null, image: null, currency: 'USD', defaultCurrency: null, diff --git a/src/components/group/AddMembers.tsx b/src/components/group/AddMembers.tsx index d74ed856f..2b8e7d6ad 100644 --- a/src/components/group/AddMembers.tsx +++ b/src/components/group/AddMembers.tsx @@ -87,25 +87,34 @@ const AddMembers: React.FC<{ function onAddEmailClick(invite = false) { if (isEmail.success) { + const email = inputValue.toLowerCase(); + addFriendMutation.mutate( - { email: inputValue.toLowerCase(), sendInviteEmail: invite }, + { email, sendInviteEmail: invite }, { onSuccess: (user) => { onSave({ ...userIds, [user.id]: true }); }, - onError: () => { - toast.error(t('errors.invite_email_failed')); - // The friend row was still created despite the email failing (see #722); - // Re-fetch it without retrying the email so it can still be added to the group. - addFriendMutation.mutate( - { email: inputValue.toLowerCase(), sendInviteEmail: false }, - { - onSuccess: (user) => { - onSave({ ...userIds, [user.id]: true }); - }, - onError: () => toast.error(t('errors.add_member_failed')), - }, + onError: (err) => { + const appErrorCode = err.data?.appErrorCode; + toast.error( + 'INVITE_EMAIL_SEND_FAILED' === appErrorCode + ? t('errors.invite_email_failed') + : t('errors.add_member_failed'), ); + + // The friend row already exists whenever this router throws, so retry the group-add. + if (null != appErrorCode) { + addFriendMutation.mutate( + { email, sendInviteEmail: false }, + { + onSuccess: (user) => { + onSave({ ...userIds, [user.id]: true }); + }, + onError: () => toast.error(t('errors.add_member_failed')), + }, + ); + } }, }, ); diff --git a/src/pages/add.tsx b/src/pages/add.tsx index d26d049e7..587e8ead0 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -66,6 +66,7 @@ const AddPage: NextPageWithUser<{ ...user, defaultCurrency: user.defaultCurrency ?? null, emailVerified: null, + lastInvitedAt: null, name: user.name ?? null, email: user.email ?? null, image: user.image ?? null, diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index a5f87a0d0..9d495d77e 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -97,6 +97,7 @@ const FriendPage: NextPageWithUser = ({ user }) => { { ...user, emailVerified: null, + lastInvitedAt: null, name: user.name ?? null, email: user.email ?? null, image: user.image ?? null, diff --git a/src/tests/addStore.test.ts b/src/tests/addStore.test.ts index ffc82790c..9cbe4a534 100644 --- a/src/tests/addStore.test.ts +++ b/src/tests/addStore.test.ts @@ -23,6 +23,7 @@ const createMockUser = (id: number, name: string, email: string): User => ({ currency: 'USD', defaultCurrency: null, emailVerified: null, + lastInvitedAt: null, image: null, preferredLanguage: 'en', obapiProviderId: null, From 31fa0284f55e05e926da2cf8051216224e3f79d3 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 08:19:54 +0530 Subject: [PATCH 10/14] refactor: consolidate invite-error codes and toast mapping into one module Both onError handlers duplicated the appErrorCode-to-toast-message mapping verbatim; extract it to src/lib/inviteErrors.ts alongside a shared InviteErrorCode constant so the router and both call sites share one source of truth instead of raw string literals. --- .../AddExpense/SelectUserOrGroup.tsx | 7 +-- src/components/group/AddMembers.tsx | 18 +++----- src/lib/inviteErrors.ts | 19 ++++++++ src/server/api/routers/user.ts | 45 ++++++++++--------- 4 files changed, 51 insertions(+), 38 deletions(-) create mode 100644 src/lib/inviteErrors.ts diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index e8242a035..35aa0fc84 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -11,6 +11,7 @@ import { z } from 'zod'; import { useAddExpenseStore } from '~/store/addStore'; import { api } from '~/utils/api'; import { deserializeDefaultSplit } from '~/lib/defaultSplit'; +import { getInviteErrorToastKey } from '~/lib/inviteErrors'; import { EntityAvatar } from '../ui/avatar'; import { Button } from '../ui/button'; @@ -59,11 +60,7 @@ export const SelectUserOrGroup: React.FC<{ }, onError: (err) => { removeParticipant(-1); - toast.error( - 'INVITE_EMAIL_SEND_FAILED' === err.data?.appErrorCode - ? t('errors.invite_email_failed') - : t('errors.add_member_failed'), - ); + toast.error(t(getInviteErrorToastKey(err.data?.appErrorCode))); }, }, ); diff --git a/src/components/group/AddMembers.tsx b/src/components/group/AddMembers.tsx index 2b8e7d6ad..b53bbf250 100644 --- a/src/components/group/AddMembers.tsx +++ b/src/components/group/AddMembers.tsx @@ -9,6 +9,7 @@ import { z } from 'zod'; import { Button } from '~/components/ui/button'; import { AppDrawer } from '~/components/ui/drawer'; +import { getInviteErrorToastKey, isInviteErrorCode } from '~/lib/inviteErrors'; import { api } from '~/utils/api'; import { EntityAvatar } from '../ui/avatar'; @@ -88,29 +89,22 @@ const AddMembers: React.FC<{ function onAddEmailClick(invite = false) { if (isEmail.success) { const email = inputValue.toLowerCase(); + const addUserToGroup = (user: { id: number }) => onSave({ ...userIds, [user.id]: true }); addFriendMutation.mutate( { email, sendInviteEmail: invite }, { - onSuccess: (user) => { - onSave({ ...userIds, [user.id]: true }); - }, + onSuccess: addUserToGroup, onError: (err) => { const appErrorCode = err.data?.appErrorCode; - toast.error( - 'INVITE_EMAIL_SEND_FAILED' === appErrorCode - ? t('errors.invite_email_failed') - : t('errors.add_member_failed'), - ); + toast.error(t(getInviteErrorToastKey(appErrorCode))); // The friend row already exists whenever this router throws, so retry the group-add. - if (null != appErrorCode) { + if (isInviteErrorCode(appErrorCode)) { addFriendMutation.mutate( { email, sendInviteEmail: false }, { - onSuccess: (user) => { - onSave({ ...userIds, [user.id]: true }); - }, + onSuccess: addUserToGroup, onError: () => toast.error(t('errors.add_member_failed')), }, ); diff --git a/src/lib/inviteErrors.ts b/src/lib/inviteErrors.ts new file mode 100644 index 000000000..e99e8d8c7 --- /dev/null +++ b/src/lib/inviteErrors.ts @@ -0,0 +1,19 @@ +export const InviteErrorCode = { + INVITES_DISABLED: 'INVITES_DISABLED', + INVITE_RATE_LIMITED: 'INVITE_RATE_LIMITED', + INVITE_EMAIL_SEND_FAILED: 'INVITE_EMAIL_SEND_FAILED', +} as const; + +const inviteErrorCodes: string[] = Object.values(InviteErrorCode); + +export function isInviteErrorCode(appErrorCode: unknown): boolean { + return 'string' === typeof appErrorCode && inviteErrorCodes.includes(appErrorCode); +} + +export function getInviteErrorToastKey( + appErrorCode: string | null | undefined, +): 'errors.invite_email_failed' | 'errors.add_member_failed' { + return InviteErrorCode.INVITE_EMAIL_SEND_FAILED === appErrorCode + ? 'errors.invite_email_failed' + : 'errors.add_member_failed'; +} diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index e003b924d..4853a9664 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -8,6 +8,7 @@ import { serializeDefaultSplit, toSortedFriendPair, } from '~/lib/defaultSplit'; +import { InviteErrorCode } from '~/lib/inviteErrors'; import { simplifyDebts } from '~/lib/simplify'; import { AppError } from '~/server/api/appError'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; @@ -28,6 +29,14 @@ import { const INVITE_COOLDOWN_MS = 60_000; +function throwInviteError( + code: 'PRECONDITION_FAILED' | 'TOO_MANY_REQUESTS' | 'INTERNAL_SERVER_ERROR', + inviteErrorCode: (typeof InviteErrorCode)[keyof typeof InviteErrorCode], + message: string, +): never { + throw new TRPCError({ code, message, cause: new AppError(inviteErrorCode, message) }); +} + export const userRouter = createTRPCRouter({ me: protectedProcedure.query(({ ctx }) => ctx.session.user), @@ -79,11 +88,11 @@ export const userRouter = createTRPCRouter({ // Only a just-created or not-yet-verified friend should get an invite email. if (input.sendInviteEmail && !friend?.emailVerified) { if (!env.ENABLE_SENDING_INVITES) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'Invite emails are disabled on this server.', - cause: new AppError('INVITES_DISABLED', 'Invite emails are disabled on this server.'), - }); + throwInviteError( + 'PRECONDITION_FAILED', + InviteErrorCode.INVITES_DISABLED, + 'Invite emails are disabled on this server.', + ); } // Claim the cooldown atomically, so concurrent requests can't both pass a read-then-write check. @@ -98,14 +107,11 @@ export const userRouter = createTRPCRouter({ data: { lastInvitedAt: new Date() }, }); if (0 === claim.count) { - throw new TRPCError({ - code: 'TOO_MANY_REQUESTS', - message: 'Please wait before re-sending an invite to this address.', - cause: new AppError( - 'INVITE_RATE_LIMITED', - 'Please wait before re-sending an invite to this address.', - ), - }); + throwInviteError( + 'TOO_MANY_REQUESTS', + InviteErrorCode.INVITE_RATE_LIMITED, + 'Please wait before re-sending an invite to this address.', + ); } const sent = await sendInviteEmail( @@ -114,14 +120,11 @@ export const userRouter = createTRPCRouter({ ); if (!sent) { console.error('Error sending invite email to user', user.id); - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to send invite email. Check your SMTP configuration.', - cause: new AppError( - 'INVITE_EMAIL_SEND_FAILED', - 'Failed to send invite email. Check your SMTP configuration.', - ), - }); + throwInviteError( + 'INTERNAL_SERVER_ERROR', + InviteErrorCode.INVITE_EMAIL_SEND_FAILED, + 'Failed to send invite email. Check your SMTP configuration.', + ); } } From b0cadd3a26002e895a6267e931602bfe7bb9a0a5 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 08:39:46 +0530 Subject: [PATCH 11/14] fix: harden inviteFriend against concurrent-create races and silent throws - Replace findUnique-then-create with db.user.upsert so two concurrent invites for the same brand-new email can't both miss the lookup and hit the unique-constraint race (a raw, unclassified error that broke the client's error-cause discrimination). - Wrap sendInviteEmail in try/catch so an unexpected throw (e.g. from the Discord-webhook notification path) still surfaces as a clean INVITE_EMAIL_SEND_FAILED instead of an unhandled 500. --- src/server/api/routers/user.ts | 38 +++++++++++++++------------------- src/server/mailer.ts | 5 ++--- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 4853a9664..02f24f4bb 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -29,13 +29,13 @@ import { const INVITE_COOLDOWN_MS = 60_000; -function throwInviteError( +const throwInviteError = ( code: 'PRECONDITION_FAILED' | 'TOO_MANY_REQUESTS' | 'INTERNAL_SERVER_ERROR', inviteErrorCode: (typeof InviteErrorCode)[keyof typeof InviteErrorCode], message: string, -): never { +): never => { throw new TRPCError({ code, message, cause: new AppError(inviteErrorCode, message) }); -} +}; export const userRouter = createTRPCRouter({ me: protectedProcedure.query(({ ctx }) => ctx.session.user), @@ -70,23 +70,18 @@ export const userRouter = createTRPCRouter({ inviteFriend: protectedProcedure .input(z.object({ email: z.string(), sendInviteEmail: z.boolean().optional() })) .mutation(async ({ input, ctx: { session } }) => { - const friend = await db.user.findUnique({ - where: { + // Upsert avoids a find-then-create race where two concurrent invites for the same brand-new email both miss the lookup and hit the unique constraint. + const user = await db.user.upsert({ + where: { email: input.email }, + update: {}, + create: { email: input.email, + name: input.email.split('@')[0], }, }); - const user = - friend ?? - (await db.user.create({ - data: { - email: input.email, - name: input.email.split('@')[0], - }, - })); - - // Only a just-created or not-yet-verified friend should get an invite email. - if (input.sendInviteEmail && !friend?.emailVerified) { + // Only a just-created or not-yet-verified user should get an invite email. + if (input.sendInviteEmail && !user.emailVerified) { if (!env.ENABLE_SENDING_INVITES) { throwInviteError( 'PRECONDITION_FAILED', @@ -114,12 +109,13 @@ export const userRouter = createTRPCRouter({ ); } - const sent = await sendInviteEmail( - input.email, - session.user.name ?? session.user.email ?? '', - ); + let sent = false; + try { + sent = await sendInviteEmail(input.email, session.user.name ?? session.user.email ?? ''); + } catch (err) { + console.error('Error sending invite email to user', user.id, err); + } if (!sent) { - console.error('Error sending invite email to user', user.id); throwInviteError( 'INTERNAL_SERVER_ERROR', InviteErrorCode.INVITE_EMAIL_SEND_FAILED, diff --git a/src/server/mailer.ts b/src/server/mailer.ts index 15d2623d2..edb702384 100644 --- a/src/server/mailer.ts +++ b/src/server/mailer.ts @@ -8,14 +8,13 @@ import { sendToDiscord } from './service-notification'; // oxlint-disable-next-line init-declarations let transporter: Transporter; -function escapeHtml(value: string): string { - return value +const escapeHtml = (value: string): string => + value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); -} export const mailServerConfig = { host: env.EMAIL_SERVER_HOST, From 135eb7519b8d5ae845c828abdbc7070942e82cc4 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 08:39:57 +0530 Subject: [PATCH 12/14] test: add coverage for inviteErrors.ts; align to project's test-structure convention inviteErrors.ts (the toast-key/error-code mapping) had zero test coverage despite being pure, framework-free logic with no harness dependency, unlike the router/component call sites. Also restructure appError.test.ts and the new file to the project's documented nested describe/scenario convention, and switch two new functions to arrow functions per AGENTS.md's stated preference. --- src/lib/inviteErrors.test.ts | 46 +++++++++++++++++++++++++++++++++ src/lib/inviteErrors.ts | 12 ++++----- src/server/api/appError.test.ts | 28 +++++++++++--------- src/server/api/appError.ts | 5 ++-- 4 files changed, 69 insertions(+), 22 deletions(-) create mode 100644 src/lib/inviteErrors.test.ts diff --git a/src/lib/inviteErrors.test.ts b/src/lib/inviteErrors.test.ts new file mode 100644 index 000000000..2fd3b602f --- /dev/null +++ b/src/lib/inviteErrors.test.ts @@ -0,0 +1,46 @@ +import { InviteErrorCode, getInviteErrorToastKey, isInviteErrorCode } from '~/lib/inviteErrors'; + +describe('isInviteErrorCode', () => { + describe('when given a known InviteErrorCode value', () => { + it.each(Object.values(InviteErrorCode))('returns true for %s', (code) => { + expect(isInviteErrorCode(code)).toBe(true); + }); + }); + + describe('when given anything else', () => { + it.each([undefined, null, 123, {}, 'SOME_UNRELATED_CODE', ''])( + 'returns false for %p', + (value) => { + expect(isInviteErrorCode(value)).toBe(false); + }, + ); + }); +}); + +describe('getInviteErrorToastKey', () => { + describe('when the code is INVITE_EMAIL_SEND_FAILED', () => { + it('returns the invite-email-failed key', () => { + expect(getInviteErrorToastKey(InviteErrorCode.INVITE_EMAIL_SEND_FAILED)).toBe( + 'errors.invite_email_failed', + ); + }); + }); + + describe('when the code is any other InviteErrorCode value', () => { + it.each([InviteErrorCode.INVITES_DISABLED, InviteErrorCode.INVITE_RATE_LIMITED])( + 'returns the generic add-member-failed key for %s', + (code) => { + expect(getInviteErrorToastKey(code)).toBe('errors.add_member_failed'); + }, + ); + }); + + describe('when the code is unknown, null, or undefined', () => { + it.each(['SOME_UNRELATED_CODE', null, undefined])( + 'returns the generic add-member-failed key for %p', + (value) => { + expect(getInviteErrorToastKey(value)).toBe('errors.add_member_failed'); + }, + ); + }); +}); diff --git a/src/lib/inviteErrors.ts b/src/lib/inviteErrors.ts index e99e8d8c7..e9ddfb871 100644 --- a/src/lib/inviteErrors.ts +++ b/src/lib/inviteErrors.ts @@ -6,14 +6,12 @@ export const InviteErrorCode = { const inviteErrorCodes: string[] = Object.values(InviteErrorCode); -export function isInviteErrorCode(appErrorCode: unknown): boolean { - return 'string' === typeof appErrorCode && inviteErrorCodes.includes(appErrorCode); -} +export const isInviteErrorCode = (appErrorCode: unknown): boolean => + 'string' === typeof appErrorCode && inviteErrorCodes.includes(appErrorCode); -export function getInviteErrorToastKey( +export const getInviteErrorToastKey = ( appErrorCode: string | null | undefined, -): 'errors.invite_email_failed' | 'errors.add_member_failed' { - return InviteErrorCode.INVITE_EMAIL_SEND_FAILED === appErrorCode +): 'errors.invite_email_failed' | 'errors.add_member_failed' => + InviteErrorCode.INVITE_EMAIL_SEND_FAILED === appErrorCode ? 'errors.invite_email_failed' : 'errors.add_member_failed'; -} diff --git a/src/server/api/appError.test.ts b/src/server/api/appError.test.ts index 4dfc95c2b..7a96fd5a5 100644 --- a/src/server/api/appError.test.ts +++ b/src/server/api/appError.test.ts @@ -3,21 +3,25 @@ import { z } from 'zod'; import { AppError, getAppErrorCode } from '~/server/api/appError'; describe('getAppErrorCode', () => { - it('returns the code for an AppError cause', () => { - expect(getAppErrorCode(new AppError('SOME_CODE', 'failed'))).toBe('SOME_CODE'); + describe('when the cause is an AppError', () => { + it('returns its code', () => { + expect(getAppErrorCode(new AppError('SOME_CODE', 'failed'))).toBe('SOME_CODE'); + }); }); - it('returns null for undefined', () => { - expect(getAppErrorCode(undefined)).toBeNull(); - }); + describe('when the cause is not an AppError', () => { + it('returns null for undefined', () => { + expect(getAppErrorCode(undefined)).toBeNull(); + }); - it('returns null for a plain Error', () => { - expect(getAppErrorCode(new Error('boom'))).toBeNull(); - }); + it('returns null for a plain Error', () => { + expect(getAppErrorCode(new Error('boom'))).toBeNull(); + }); - it('returns null for a ZodError', () => { - const result = z.string().safeParse(123); - expect(result.success).toBe(false); - expect(getAppErrorCode(!result.success ? result.error : undefined)).toBeNull(); + it('returns null for a ZodError', () => { + const result = z.string().safeParse(123); + expect(result.success).toBe(false); + expect(getAppErrorCode(!result.success ? result.error : undefined)).toBeNull(); + }); }); }); diff --git a/src/server/api/appError.ts b/src/server/api/appError.ts index 8369444da..5eb1cdf67 100644 --- a/src/server/api/appError.ts +++ b/src/server/api/appError.ts @@ -8,6 +8,5 @@ export class AppError extends Error { } } -export function getAppErrorCode(cause: unknown): string | null { - return cause instanceof AppError ? cause.code : null; -} +export const getAppErrorCode = (cause: unknown): string | null => + cause instanceof AppError ? cause.code : null; From 13e6ee495792bfef2e6605ef9350ac2e108327a8 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 08:40:05 +0530 Subject: [PATCH 13/14] fix: apply the same error-recovery pattern to SelectUserOrGroup.tsx AddMembers.tsx already retries without re-sending the email and adds the participant when inviteFriend fails for a genuine invite-related reason, since the target row exists by the time this router can throw. SelectUserOrGroup.tsx previously only showed a toast and dropped the optimistic placeholder, leaving the participant unadded even though the row was created. --- .../AddExpense/SelectUserOrGroup.tsx | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index 35aa0fc84..87fadafd4 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -11,7 +11,7 @@ import { z } from 'zod'; import { useAddExpenseStore } from '~/store/addStore'; import { api } from '~/utils/api'; import { deserializeDefaultSplit } from '~/lib/defaultSplit'; -import { getInviteErrorToastKey } from '~/lib/inviteErrors'; +import { getInviteErrorToastKey, isInviteErrorCode } from '~/lib/inviteErrors'; import { EntityAvatar } from '../ui/avatar'; import { Button } from '../ui/button'; @@ -50,17 +50,36 @@ export const SelectUserOrGroup: React.FC<{ const onAddEmailClick = useCallback( (invite = false) => { if (isEmail.success) { + const email = nameOrEmail; + const addParticipant = (user: User) => { + removeParticipant(-1); + addOrUpdateParticipant(user); + setNameOrEmail(''); + }; + addFriendMutation.mutate( - { email: nameOrEmail, sendInviteEmail: invite }, + { email, sendInviteEmail: invite }, { - onSuccess: (user) => { - removeParticipant(-1); - addOrUpdateParticipant(user); - setNameOrEmail(''); - }, + onSuccess: addParticipant, onError: (err) => { - removeParticipant(-1); - toast.error(t(getInviteErrorToastKey(err.data?.appErrorCode))); + const appErrorCode = err.data?.appErrorCode; + toast.error(t(getInviteErrorToastKey(appErrorCode))); + + // The friend row already exists whenever this router throws, so retry the participant-add. + if (isInviteErrorCode(appErrorCode)) { + addFriendMutation.mutate( + { email, sendInviteEmail: false }, + { + onSuccess: addParticipant, + onError: () => { + removeParticipant(-1); + toast.error(t('errors.add_member_failed')); + }, + }, + ); + } else { + removeParticipant(-1); + } }, }, ); From 1c9bd6803fbfd2f502c9fc157ad9e8a3b45bcd84 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 6 Sep 2026 15:42:40 +0530 Subject: [PATCH 14/14] fix: wire the send_invite button to actually send an invite email Both buttons in SelectUserOrGroup called onAddEmailClick(false), so clicking "Send invite" silently added the friend without ever sending the invite email (CodeRabbit finding on the round-2 push). --- src/components/AddExpense/SelectUserOrGroup.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index 87fadafd4..9c0d9391b 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -132,6 +132,7 @@ export const SelectUserOrGroup: React.FC<{ [setGroup, setParticipants, setNameOrEmail], ); + const handleAddEmailClickTrue = useCallback(() => onAddEmailClick(true), [onAddEmailClick]); const handleAddEmailClickFalse = useCallback(() => onAddEmailClick(false), [onAddEmailClick]); if (group) { @@ -176,7 +177,7 @@ export const SelectUserOrGroup: React.FC<{ className="mt-4 text-cyan-500 hover:text-cyan-500" variant="outline" disabled={!isEmail.success} - onClick={handleAddEmailClickFalse} + onClick={handleAddEmailClickTrue} > {t('expense_details.add_expense_details.select_user_or_group.send_invite')}