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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 *********
Expand Down
1 change: 1 addition & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"deleted_user": "Deleted user",
"all": "All",
"friends": "Friends",
"other_users": "Other users",
"groups": "Groups",
"members": "Members",
"owner": "Owner",
Expand Down
60 changes: 52 additions & 8 deletions src/components/AddExpense/SelectUserOrGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand All @@ -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,
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -183,11 +193,18 @@ export const SelectUserOrGroup: React.FC<{
<p>{t('ui.people_list_load_error')}</p>
<Button
variant="outline"
disabled={friendsQuery.isFetching || (canSelectGroups && groupsQuery.isFetching)}
disabled={
friendsQuery.isFetching ||
discoveryQuery.isFetching ||
(canSelectGroups && groupsQuery.isFetching)
}
onClick={() => {
if (friendsQuery.isError) {
void friendsQuery.refetch();
}
if (discoveryQuery.isError) {
void discoveryQuery.refetch();
}
if (canSelectGroups && groupsQuery.isError) {
void groupsQuery.refetch();
}
Expand Down Expand Up @@ -221,6 +238,30 @@ export const SelectUserOrGroup: React.FC<{
</>
) : null}

{filteredOtherUsers?.length ? (
<>
<div className="font-normal text-gray-500">{t('actors.other_users')}</div>
{filteredOtherUsers.map((f) => (
<button
disabled={adding}
key={f.id}
className="flex w-full items-center justify-between border-b border-gray-900 py-4"
onClick={() => handleFriendClick(f)}
>
<div className="flex min-w-0 items-center gap-4">
<EntityAvatar entity={f} size={35} />
<div className="truncate">{displayName(f)}</div>
</div>
{participants.some((p) => p.id === f.id) ? (
<div>
<CheckIcon className="text-primary h-4 w-4" />
</div>
) : null}
</button>
))}
</>
) : null}

{/*Can't select multiple groups or groups with outside ppl */}
{filteredGroups?.length && 1 === participants.length ? (
<>
Expand All @@ -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 ? (
<div className="mt-[30%] flex flex-col items-center justify-center gap-8 transition-discrete starting:opacity-0">
<Image
alt={t('expense_details.add_expense_details.select_user_or_group.empty_image_alt')}
Expand Down
7 changes: 0 additions & 7 deletions src/components/AddExpense/UserInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
});
}
};
Expand Down
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const env = createEnv({
CLEAR_CACHE_CRON_RULE: z.string().optional(),
CACHE_RETENTION_INTERVAL: z.string().optional(),
ENABLE_SENDING_INVITES: z.boolean(),
ENABLE_USER_DISCOVERY: z.boolean().default(false),
EXCLUDE_FUTURE_EXPENSES: z.boolean().default(false),
DISABLE_EMAIL_SIGNUP: z.boolean(),
INVITE_ONLY: z.boolean(),
Expand Down Expand Up @@ -119,6 +120,7 @@ export const env = createEnv({
CLEAR_CACHE_CRON_RULE: process.env.CLEAR_CACHE_CRON_RULE ?? '0 2 * * 0',
CACHE_RETENTION_INTERVAL: process.env.CACHE_RETENTION_INTERVAL ?? '2 days',
ENABLE_SENDING_INVITES: parseEnvBoolean(process.env.ENABLE_SENDING_INVITES),
ENABLE_USER_DISCOVERY: parseEnvBoolean(process.env.ENABLE_USER_DISCOVERY),
EXCLUDE_FUTURE_EXPENSES: parseEnvBoolean(process.env.EXCLUDE_FUTURE_EXPENSES),
DISABLE_EMAIL_SIGNUP: parseEnvBoolean(process.env.DISABLE_EMAIL_SIGNUP),
INVITE_ONLY: parseEnvBoolean(process.env.INVITE_ONLY),
Expand Down
9 changes: 1 addition & 8 deletions src/pages/balances/[friendId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,11 @@ const FriendPage: NextPageWithUser = ({ user }) => {
<DefaultSplitSettings
participants={[
{
...user,
id: user.id,
deletedAt: null,
emailVerified: null,
name: user.name ?? null,
email: user.email ?? null,
image: user.image ?? null,
obapiProviderId: user.obapiProviderId ?? null,
bankingId: user.bankingId ?? null,
preferredLanguage: user.preferredLanguage ?? '',
hiddenFriendIds: user.hiddenFriendIds ?? [],
currency: user.currency ?? 'USD',
defaultCurrency: user.defaultCurrency ?? null,
},
friendQuery.data,
]}
Expand Down
21 changes: 21 additions & 0 deletions src/server/api/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,27 @@ export const userRouter = createTRPCRouter({
return friends.map((f) => 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({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
where: {
deletedAt: null,
id: { notIn: [ctx.session.user.id, ...friends.map((friend) => friend.friendId)] },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
select: { id: true, name: true, image: true },
Comment thread
krokosik marked this conversation as resolved.
Resolved
orderBy: [{ name: 'asc' }, { id: 'asc' }],
});
}),

getOwnExpenses: protectedProcedure.query(async ({ ctx }) => {
const expenses = await db.expense.findMany({
where: {
Expand Down
9 changes: 5 additions & 4 deletions src/store/addStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, 'id' | 'name' | 'image'> &
Partial<Pick<User, 'email' | 'deletedAt'>> & { amount?: bigint };
export type SplitShares = Record<number, Record<SplitType, bigint | undefined>>;

export interface AddExpenseState {
Expand All @@ -26,7 +27,7 @@ export interface AddExpenseState {
currency: CurrencyCode;
category: string;
nameOrEmail: string;
paidBy?: User;
paidBy?: Participant;
showFriends: boolean;
isFileUploading: boolean;
fileKey?: string;
Expand All @@ -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;
Expand Down Expand Up @@ -395,7 +396,7 @@ export function calculateSplitShareBasedOnAmount(
participants: Participant[],
splitType: SplitType,
splitShares: SplitShares,
paidBy?: User,
paidBy?: Participant,
) {
switch (splitType) {
case SplitType.EQUAL:
Expand Down
26 changes: 26 additions & 0 deletions src/tests/addMembers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +41,7 @@ const mockInvalidate = jest.fn().mockResolvedValue(undefined);
jest.mock('~/utils/api', () => ({
api: {
user: {
getDiscoverableUsers: { useQuery: () => mockDiscoveryQuery() },
getFriends: {
useQuery: () => mockFriendsQuery(),
},
Expand Down Expand Up @@ -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(<SelectUserOrGroup enableSendingInvites={false} />);
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(<SelectUserOrGroup enableSendingInvites />);
Expand Down
77 changes: 77 additions & 0 deletions src/tests/integration/userDiscovery.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Loading