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..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 { @@ -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, }); @@ -133,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) { @@ -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)] }, + }, + select: { id: true, name: true, image: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + }), + getOwnExpenses: protectedProcedure.query(async ({ ctx }) => { const expenses = await db.expense.findMany({ where: { 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 754bb4b0..9269cf81 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', image: null }, + { id: 5, name: 'Other person', image: null }, + ], + 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..45526bee --- /dev/null +++ b/src/tests/integration/userDiscovery.integration.test.ts @@ -0,0 +1,77 @@ +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('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({ + code: 'UNAUTHORIZED', + }); + }); +});