From 73d7befcca99c3f0a7acb142573dbad59d3ac9d0 Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 4 Nov 2026 20:12:24 +0100 Subject: [PATCH 1/2] feat: add optional user discovery in expense picker --- .env.example | 4 ++ docs/CONFIGURATION.md | 1 + public/locales/en/common.json | 1 + .../AddExpense/SelectUserOrGroup.tsx | 56 ++++++++++++++++-- src/env.ts | 2 + src/server/api/routers/user.ts | 20 +++++++ src/tests/addMembers.test.tsx | 26 ++++++++ .../userDiscovery.integration.test.ts | 59 +++++++++++++++++++ 8 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 src/tests/integration/userDiscovery.integration.test.ts diff --git a/.env.example b/.env.example index fc533c7d..cb33a134 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,10 @@ DEFAULT_HOMEPAGE="/home" # Enable sending invites ENABLE_SENDING_INVITES=false +# Show other users (including hidden friends) in the expense participant picker. +# Enable only on private instances where all users may discover each other. +ENABLE_USER_DISCOVERY=false + # Disable email signup (magic link/OTP login) for new users DISABLE_EMAIL_SIGNUP=false #********* END OF REQUIRED ENV VARS ********* diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6e6eb6de..c7be2293 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -64,6 +64,7 @@ At least one provider must be configured. SplitPro does not support username/pas ### App behavior - `DEFAULT_HOMEPAGE`: Sets the landing page route, e.g. `/home` or `/balances`. +- `ENABLE_USER_DISCOVERY`: Default `false`. Show an “Other users” section in the expense participant picker, including hidden friends and users without shared expenses. Excludes yourself and deleted accounts. Enable for private instances where all users may discover each other; this does not add friends to the balance dashboard. - `ENABLE_SENDING_INVITES`: Enable email invites (requires SMTP config). - `DISABLE_EMAIL_SIGNUP`: Disable email magic-link signup for new users. - `UPLOAD_MAX_FILE_SIZE_MB`: Maximum receipt upload size in MB. Used by both client-side pre-check and server-side upload limit. This is also the max size target for image compression, so increase it if you see bad resolution/quality. Default: `10`. diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 99015095..c6d88427 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -114,6 +114,7 @@ "deleted_user": "Deleted user", "all": "All", "friends": "Friends", + "other_users": "Other users", "groups": "Groups", "members": "Members", "owner": "Owner", diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index eedade06..1fdf6185 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -40,6 +40,7 @@ export const SelectUserOrGroup: React.FC<{ useAddExpenseStore((s) => s.actions); const friendsQuery = api.user.getFriends.useQuery(); + const discoveryQuery = api.user.getDiscoverableUsers.useQuery(); const groupsQuery = api.group.getAllGroups.useQuery(); const addFriendMutation = api.user.inviteFriend.useMutation(); @@ -53,20 +54,29 @@ export const SelectUserOrGroup: React.FC<{ const canSelectGroups = 1 === participants.length; const contactsFailed = (friendsQuery.isError && undefined === friendsQuery.data) || + (discoveryQuery.isError && undefined === discoveryQuery.data) || (canSelectGroups && groupsQuery.isError && undefined === groupsQuery.data); const filteredGroups = canSelectGroups ? groupsQuery.data?.filter((g) => g.group.name.toLowerCase().includes(searchValue)) : []; - const filteredFriends = friendsQuery.data?.filter((f) => - displayName(f).toLowerCase().includes(searchValue), + const filteredFriends = friendsQuery.data?.filter( + (f) => !f.deletedAt && displayName(f).toLowerCase().includes(searchValue), ); - /* Both friends and groups populate this list, so either one counts as having contacts. */ + const filteredOtherUsers = discoveryQuery.data?.filter((user) => + displayName(user).toLowerCase().includes(searchValue), + ); + + /* Friends, discoverable users, and groups all count as contacts. */ const emptyReason = peopleListEmptyReason({ isLoading: - contactsFailed || friendsQuery.isPending || (canSelectGroups && groupsQuery.isPending), + contactsFailed || + friendsQuery.isPending || + discoveryQuery.isPending || + (canSelectGroups && groupsQuery.isPending), hasAnyContacts: 0 < (friendsQuery.data?.length ?? 0) || + 0 < (discoveryQuery.data?.length ?? 0) || (canSelectGroups && 0 < (groupsQuery.data?.length ?? 0)), isFiltering: '' !== searchValue, }); @@ -183,11 +193,18 @@ export const SelectUserOrGroup: React.FC<{

{t('ui.people_list_load_error')}

+ ))} + + ) : null} + {/*Can't select multiple groups or groups with outside ppl */} {filteredGroups?.length && 1 === participants.length ? ( <> @@ -243,7 +284,10 @@ export const SelectUserOrGroup: React.FC<{ ) : null} - {!contactsFailed && 0 === filteredFriends?.length && 0 === filteredGroups?.length ? ( + {!contactsFailed && + 0 === filteredFriends?.length && + 0 === filteredOtherUsers?.length && + 0 === filteredGroups?.length ? (
{t('expense_details.add_expense_details.select_user_or_group.empty_image_alt')} f.friend); }), + getDiscoverableUsers: protectedProcedure.query(async ({ ctx }) => { + if (!env.ENABLE_USER_DISCOVERY) { + return []; + } + + const friends = await ctx.db.balanceView.findMany({ + where: { userId: ctx.session.user.id, friendId: { notIn: ctx.session.user.hiddenFriendIds } }, + select: { friendId: true }, + distinct: ['friendId'], + }); + + return ctx.db.user.findMany({ + where: { + deletedAt: null, + id: { notIn: [ctx.session.user.id, ...friends.map((friend) => friend.friendId)] }, + }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + }), + getOwnExpenses: protectedProcedure.query(async ({ ctx }) => { const expenses = await db.expense.findMany({ where: { diff --git a/src/tests/addMembers.test.tsx b/src/tests/addMembers.test.tsx index 754bb4b0..8d3a43be 100644 --- a/src/tests/addMembers.test.tsx +++ b/src/tests/addMembers.test.tsx @@ -12,9 +12,16 @@ import { type AppDrawer } from '~/components/ui/drawer'; import { renderWithProviders } from '~/tests/helpers/render'; const mockFriendsQuery = jest.fn(); +const mockDiscoveryQuery = jest.fn(); const mockGroupsQuery = jest.fn(); const mockRefetch = jest.fn(); const resetQueries = () => { + mockDiscoveryQuery.mockReturnValue({ + data: [], + isPending: false, + isError: false, + refetch: mockRefetch, + }); mockFriendsQuery.mockReturnValue({ data: [{ id: 2, name: 'Alex', email: 'alex@example.com' }], isPending: false, @@ -34,6 +41,7 @@ const mockInvalidate = jest.fn().mockResolvedValue(undefined); jest.mock('~/utils/api', () => ({ api: { user: { + getDiscoverableUsers: { useQuery: () => mockDiscoveryQuery() }, getFriends: { useQuery: () => mockFriendsQuery(), }, @@ -247,6 +255,24 @@ describe('Expense participant invitations', () => { expect(mockRefetch).toHaveBeenCalledTimes(1); }); + it('filters and selects discovered users in their own section', () => { + mockDiscoveryQuery.mockReturnValue({ + data: [ + { id: 4, name: 'Hidden friend', email: 'hidden@example.com' }, + { id: 5, name: 'Other person', email: 'other@example.com' }, + ], + isPending: false, + isError: false, + }); + useAddExpenseStore.setState({ nameOrEmail: 'hidden', participants: [] }); + renderWithProviders(); + expect(screen.getByText('Other users')).toBeInTheDocument(); + expect(screen.queryByText('Other person')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Hidden friend' })); + expect(useAddExpenseStore.getState().participants.map((user) => user.id)).toEqual([4]); + expect(useAddExpenseStore.getState().nameOrEmail).toBe(''); + }); + it('adds to the draft and reports partial success after the email fallback', async () => { mockInvite.mockRejectedValueOnce({ data: { appErrorCode: 'INVITE_EMAIL_SEND_FAILED' } }); renderWithProviders(); diff --git a/src/tests/integration/userDiscovery.integration.test.ts b/src/tests/integration/userDiscovery.integration.test.ts new file mode 100644 index 00000000..4882267d --- /dev/null +++ b/src/tests/integration/userDiscovery.integration.test.ts @@ -0,0 +1,59 @@ +import { env } from '~/env'; +import { appRouter } from '~/server/api/root'; +import { createInnerTRPCContext } from '~/server/api/trpc'; + +import { db } from './database'; +import { expenseInput, testUser } from './factories'; +import { callerFor, sessionFor } from './trpc'; + +describe('user discovery', () => { + const originalEnabled = env.ENABLE_USER_DISCOVERY; + + afterEach(() => { + jest.replaceProperty(env, 'ENABLE_USER_DISCOVERY', originalEnabled); + }); + + it('returns no other users when disabled', async () => { + jest.replaceProperty(env, 'ENABLE_USER_DISCOVERY', false); + const owner = await testUser(); + await testUser('Other user'); + + expect(await callerFor(owner.id).user.getDiscoverableUsers()).toEqual([]); + }); + + it('discovers strangers and hidden friends without changing the friends list', async () => { + jest.replaceProperty(env, 'ENABLE_USER_DISCOVERY', true); + const owner = await testUser('Owner'); + const friend = await testUser('Friend'); + const hidden = await testUser('Hidden friend'); + const stranger = await testUser('Stranger'); + const deleted = await testUser('Deleted user'); + await db.user.update({ where: { id: deleted.id }, data: { deletedAt: new Date() } }); + await callerFor(owner.id).expense.addOrEditExpense(expenseInput(owner.id, friend.id)); + await callerFor(owner.id).expense.addOrEditExpense(expenseInput(owner.id, hidden.id)); + await callerFor(owner.id).expense.addOrEditExpense(expenseInput(hidden.id, owner.id)); + await db.user.update({ + where: { id: owner.id }, + data: { hiddenFriendIds: [hidden.id] }, + }); + const session = sessionFor(owner.id); + session.user.hiddenFriendIds = [hidden.id]; + const caller = appRouter.createCaller(createInnerTRPCContext({ session, db })); + + const discovered = await caller.user.getDiscoverableUsers(); + expect(discovered.map((user) => user.id).sort((a, b) => a - b)).toEqual( + [hidden.id, stranger.id].sort((a, b) => a - b), + ); + expect((await caller.user.getFriends()).map((user) => user.id)).toEqual([friend.id]); + expect((await db.user.findUniqueOrThrow({ where: { id: owner.id } })).hiddenFriendIds).toEqual([ + hidden.id, + ]); + }); + + it('requires authentication even when enabled', async () => { + jest.replaceProperty(env, 'ENABLE_USER_DISCOVERY', true); + await expect(callerFor(null).user.getDiscoverableUsers()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); +}); From 1b009f9996848ba273af16e8802c517d4114f577 Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 4 Nov 2026 20:23:08 +0100 Subject: [PATCH 2/2] fix: limit discovered users to picker identity fields --- .../AddExpense/SelectUserOrGroup.tsx | 4 ++-- src/components/AddExpense/UserInput.tsx | 7 ------- src/pages/balances/[friendId].tsx | 9 +-------- src/server/api/routers/user.ts | 1 + src/store/addStore.ts | 9 +++++---- src/tests/addMembers.test.tsx | 4 ++-- .../userDiscovery.integration.test.ts | 18 ++++++++++++++++++ 7 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index 1fdf6185..cd5ece49 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -7,7 +7,7 @@ import { toast } from 'sonner'; import { z } from 'zod'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; -import { useAddExpenseStore } from '~/store/addStore'; +import { type Participant, useAddExpenseStore } from '~/store/addStore'; import { api } from '~/utils/api'; import { deserializeDefaultSplit } from '~/lib/defaultSplit'; import { @@ -143,7 +143,7 @@ export const SelectUserOrGroup: React.FC<{ ); const handleFriendClick = useCallback( - (f: User) => { + (f: Participant) => { const isExisting = participants.some((p) => p.id === f.id); if (isExisting) { diff --git a/src/components/AddExpense/UserInput.tsx b/src/components/AddExpense/UserInput.tsx index 86aa726c..d473a96b 100644 --- a/src/components/AddExpense/UserInput.tsx +++ b/src/components/AddExpense/UserInput.tsx @@ -60,14 +60,7 @@ export const UserInput: React.FC<{ name: nameOrEmail, email: nameOrEmail, deletedAt: null, - emailVerified: new Date(), image: null, - currency: 'USD', - defaultCurrency: null, - obapiProviderId: null, - bankingId: null, - preferredLanguage: '', - hiddenFriendIds: [], }); } }; diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index 0bc9f649..392c0099 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -99,18 +99,11 @@ const FriendPage: NextPageWithUser = ({ user }) => { friend.friendId)] }, }, + select: { id: true, name: true, image: true }, orderBy: [{ name: 'asc' }, { id: 'asc' }], }); }), diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 993f4121..ee1b6de9 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -10,7 +10,8 @@ import { shuffleArray } from '~/utils/array'; import { BigMath } from '~/utils/numbers'; import { cyrb128, splitmix32 } from '~/utils/random'; -export type Participant = User & { amount?: bigint }; +export type Participant = Pick & + Partial> & { amount?: bigint }; export type SplitShares = Record>; export interface AddExpenseState { @@ -26,7 +27,7 @@ export interface AddExpenseState { currency: CurrencyCode; category: string; nameOrEmail: string; - paidBy?: User; + paidBy?: Participant; showFriends: boolean; isFileUploading: boolean; fileKey?: string; @@ -51,7 +52,7 @@ export interface AddExpenseState { setCurrency: (currency: CurrencyCode) => void; setCategory: (category: string) => void; setNameOrEmail: (nameOrEmail: string) => void; - setPaidBy: (user: User) => void; + setPaidBy: (user: Participant) => void; setCurrentUser: (user: User) => void; setDescription: (description: string) => void; setFileUploading: (isFileUploading: boolean) => void; @@ -395,7 +396,7 @@ export function calculateSplitShareBasedOnAmount( participants: Participant[], splitType: SplitType, splitShares: SplitShares, - paidBy?: User, + paidBy?: Participant, ) { switch (splitType) { case SplitType.EQUAL: diff --git a/src/tests/addMembers.test.tsx b/src/tests/addMembers.test.tsx index 8d3a43be..9269cf81 100644 --- a/src/tests/addMembers.test.tsx +++ b/src/tests/addMembers.test.tsx @@ -258,8 +258,8 @@ describe('Expense participant invitations', () => { it('filters and selects discovered users in their own section', () => { mockDiscoveryQuery.mockReturnValue({ data: [ - { id: 4, name: 'Hidden friend', email: 'hidden@example.com' }, - { id: 5, name: 'Other person', email: 'other@example.com' }, + { id: 4, name: 'Hidden friend', image: null }, + { id: 5, name: 'Other person', image: null }, ], isPending: false, isError: false, diff --git a/src/tests/integration/userDiscovery.integration.test.ts b/src/tests/integration/userDiscovery.integration.test.ts index 4882267d..45526bee 100644 --- a/src/tests/integration/userDiscovery.integration.test.ts +++ b/src/tests/integration/userDiscovery.integration.test.ts @@ -50,6 +50,24 @@ describe('user discovery', () => { ]); }); + it('returns only picker identity fields for unrelated users', async () => { + jest.replaceProperty(env, 'ENABLE_USER_DISCOVERY', true); + const owner = await testUser('Owner'); + const stranger = await testUser('Stranger'); + await db.user.update({ + where: { id: stranger.id }, + data: { + bankingId: 'private-bank-id', + obapiProviderId: 'private-provider-id', + hiddenFriendIds: [owner.id], + }, + }); + + expect(await callerFor(owner.id).user.getDiscoverableUsers()).toEqual([ + { id: stranger.id, name: stranger.name, image: stranger.image }, + ]); + }); + it('requires authentication even when enabled', async () => { jest.replaceProperty(env, 'ENABLE_USER_DISCOVERY', true); await expect(callerFor(null).user.getDiscoverableUsers()).rejects.toMatchObject({