Skip to content
Open
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,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastInvitedAt" TIMESTAMP(3);
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ model User {
name String?
email String? @unique
emailVerified DateTime?
lastInvitedAt DateTime?
image String?
currency String @default("USD")
defaultCurrency String?
Expand Down
3 changes: 2 additions & 1 deletion public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 34 additions & 6 deletions src/components/AddExpense/SelectUserOrGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ 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';
import { api } from '~/utils/api';
import { deserializeDefaultSplit } from '~/lib/defaultSplit';
import { getInviteErrorToastKey, isInviteErrorCode } from '~/lib/inviteErrors';

import { EntityAvatar } from '../ui/avatar';
import { Button } from '../ui/button';
Expand Down Expand Up @@ -48,13 +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 },
Comment thread
SomSamantray marked this conversation as resolved.
{
onSuccess: (user) => {
removeParticipant(-1);
addOrUpdateParticipant(user);
setNameOrEmail('');
onSuccess: addParticipant,
onError: (err) => {
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);
}
},
},
);
Expand All @@ -63,6 +88,7 @@ export const SelectUserOrGroup: React.FC<{
name: nameOrEmail,
email: nameOrEmail,
emailVerified: new Date(),
lastInvitedAt: null,
image: null,
currency: 'USD',
defaultCurrency: null,
Expand All @@ -81,6 +107,7 @@ export const SelectUserOrGroup: React.FC<{
addOrUpdateParticipant,
setNameOrEmail,
removeParticipant,
t,
],
);

Expand All @@ -105,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) {
Expand Down Expand Up @@ -149,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}
>
<SendIcon className="mr-2 h-4 w-4" />
{t('expense_details.add_expense_details.select_user_or_group.send_invite')}
Expand Down
1 change: 1 addition & 0 deletions src/components/AddExpense/UserInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const UserInput: React.FC<{
name: nameOrEmail,
email: nameOrEmail,
emailVerified: new Date(),
lastInvitedAt: null,
image: null,
currency: 'USD',
defaultCurrency: null,
Expand Down
23 changes: 20 additions & 3 deletions src/components/group/AddMembers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,11 +88,27 @@ 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: inputValue.toLowerCase(), sendInviteEmail: invite },
{ email, sendInviteEmail: invite },
{
onSuccess: (user) => {
onSave({ ...userIds, [user.id]: true });
onSuccess: addUserToGroup,
onError: (err) => {
const appErrorCode = err.data?.appErrorCode;
toast.error(t(getInviteErrorToastKey(appErrorCode)));

// The friend row already exists whenever this router throws, so retry the group-add.
if (isInviteErrorCode(appErrorCode)) {
addFriendMutation.mutate(
{ email, sendInviteEmail: false },
{
onSuccess: addUserToGroup,
onError: () => toast.error(t('errors.add_member_failed')),
},
);
}
},
},
);
Expand Down
46 changes: 46 additions & 0 deletions src/lib/inviteErrors.test.ts
Original file line number Diff line number Diff line change
@@ -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');
},
);
});
});
17 changes: 17 additions & 0 deletions src/lib/inviteErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 const isInviteErrorCode = (appErrorCode: unknown): boolean =>
'string' === typeof appErrorCode && inviteErrorCodes.includes(appErrorCode);

export const getInviteErrorToastKey = (
appErrorCode: string | null | undefined,
): 'errors.invite_email_failed' | 'errors.add_member_failed' =>
InviteErrorCode.INVITE_EMAIL_SEND_FAILED === appErrorCode
? 'errors.invite_email_failed'
: 'errors.add_member_failed';
1 change: 1 addition & 0 deletions src/pages/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/pages/balances/[friendId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions src/server/api/appError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from 'zod';

import { AppError, getAppErrorCode } from '~/server/api/appError';

describe('getAppErrorCode', () => {
describe('when the cause is an AppError', () => {
it('returns its code', () => {
expect(getAppErrorCode(new AppError('SOME_CODE', 'failed'))).toBe('SOME_CODE');
});
});

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 ZodError', () => {
const result = z.string().safeParse(123);
expect(result.success).toBe(false);
expect(getAppErrorCode(!result.success ? result.error : undefined)).toBeNull();
});
});
});
12 changes: 12 additions & 0 deletions src/server/api/appError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export class AppError extends Error {
readonly code: string;

constructor(code: string, message: string) {
super(message);
this.name = 'AppError';
this.code = code;
}
}

export const getAppErrorCode = (cause: unknown): string | null =>
cause instanceof AppError ? cause.code : null;
73 changes: 58 additions & 15 deletions src/server/api/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ 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';
import { db } from '~/server/db';
import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer';
Expand All @@ -25,6 +27,16 @@ import {
importUserBalanceFromSplitWise,
} from '../services/splitService';

const INVITE_COOLDOWN_MS = 60_000;

const 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),

Expand Down Expand Up @@ -58,27 +70,58 @@ 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: {
email: input.email,
},
});

if (friend) {
return friend;
}

const user = await db.user.create({
data: {
// 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],
},
});

if (input.sendInviteEmail) {
sendInviteEmail(input.email, session.user.name ?? session.user.email ?? '').catch((err) => {
console.error('Error sending invite email', err);
// 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',
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.
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) {
throwInviteError(
'TOO_MANY_REQUESTS',
InviteErrorCode.INVITE_RATE_LIMITED,
'Please wait before re-sending an invite to this address.',
);
}

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) {
throwInviteError(
'INTERNAL_SERVER_ERROR',
InviteErrorCode.INVITE_EMAIL_SEND_FAILED,
'Failed to send invite email. Check your SMTP configuration.',
);
}
}

return user;
Expand Down
Loading