Skip to content

Commit 59ecb73

Browse files
Skip changes in this commit
Intentionally empty to serve as placeholder for future updates.
1 parent 80d0ecb commit 59ecb73

4 files changed

Lines changed: 131 additions & 10 deletions

File tree

apps/api/src/services/user-service.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,40 @@ import { AppError } from '../lib/http';
77
const isUniqueViolation = (error: unknown): boolean =>
88
typeof error === 'object' && error !== null && 'code' in error && error.code === '23505';
99

10+
const isBetterAuthConflict = (
11+
error: unknown,
12+
code: string,
13+
): error is {
14+
statusCode?: number;
15+
message?: string;
16+
body?: {
17+
code?: string;
18+
message?: string;
19+
};
20+
} => {
21+
if (typeof error !== 'object' || error === null) {
22+
return false;
23+
}
24+
25+
const candidate = error as {
26+
statusCode?: number;
27+
message?: string;
28+
body?: {
29+
code?: string;
30+
message?: string;
31+
};
32+
};
33+
34+
if (candidate.body?.code === code) {
35+
return true;
36+
}
37+
38+
const normalizedMessage = candidate.message?.toLowerCase();
39+
const normalizedBodyMessage = candidate.body?.message?.toLowerCase();
40+
41+
return Boolean(normalizedMessage && normalizedBodyMessage && normalizedMessage.includes(normalizedBodyMessage));
42+
};
43+
1044
const toIsoString = (value: Date | string): string =>
1145
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
1246

@@ -88,6 +122,17 @@ export class UserService {
88122
throw new AppError(409, 'CONFLICT', 'A pending invitation already exists for that email');
89123
}
90124

125+
if (
126+
isBetterAuthConflict(error, 'USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION') ||
127+
isBetterAuthConflict(error, 'USER_ALREADY_MEMBER_OF_ORGANIZATION')
128+
) {
129+
throw new AppError(
130+
409,
131+
'CONFLICT',
132+
error.body?.message ?? 'A pending invitation already exists for that email',
133+
);
134+
}
135+
91136
throw error;
92137
}
93138
}

apps/api/src/tests/app.test.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,17 @@ const authContext: {
6464
};
6565

6666
let currentAuthContext = authContext;
67+
const { createInvitationMock } = vi.hoisted(() => ({
68+
createInvitationMock: vi.fn(async () => ({
69+
id: 'a079fe59-bcec-4ceb-a07b-dc0a439e0d76',
70+
})),
71+
}));
6772

6873
vi.mock('@acme/auth', () => ({
6974
canManageMembers: (role: string | null | undefined) => role === 'owner' || role === 'admin',
7075
auth: {
7176
api: {
72-
createInvitation: vi.fn(async () => ({
73-
id: 'a079fe59-bcec-4ceb-a07b-dc0a439e0d76',
74-
})),
77+
createInvitation: createInvitationMock,
7578
},
7679
},
7780
resolveAuthContext: vi.fn(async (headers: Headers) =>
@@ -149,6 +152,10 @@ describe('api routes', () => {
149152
beforeEach(() => {
150153
repository = createRepository();
151154
currentAuthContext = authContext;
155+
createInvitationMock.mockReset();
156+
createInvitationMock.mockResolvedValue({
157+
id: 'a079fe59-bcec-4ceb-a07b-dc0a439e0d76',
158+
});
152159
});
153160

154161
it('returns health metadata', async () => {
@@ -263,6 +270,45 @@ describe('api routes', () => {
263270
expect(body.data.invitationId).toBe('a079fe59-bcec-4ceb-a07b-dc0a439e0d76');
264271
});
265272

273+
it('returns a conflict when Better Auth reports an existing invitation', async () => {
274+
createInvitationMock.mockRejectedValueOnce({
275+
message: 'User is already invited to this organization',
276+
statusCode: 400,
277+
body: {
278+
code: 'USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION',
279+
message: 'User is already invited to this organization',
280+
},
281+
});
282+
283+
const app = createApp({
284+
env: loadApiEnv({
285+
DATABASE_URL: 'postgres://postgres:postgres@localhost:5432/acme_platform',
286+
}),
287+
usersRepository: repository,
288+
});
289+
290+
const response = await app.request('/api/v1/invitations', {
291+
method: 'POST',
292+
headers: {
293+
'content-type': 'application/json',
294+
cookie: 'session=valid',
295+
},
296+
body: JSON.stringify({
297+
email: 'grace@example.com',
298+
role: 'member',
299+
}),
300+
});
301+
const body = (await response.json()) as ApiResponse<never>;
302+
303+
expect(response.status).toBe(409);
304+
expect(body.success).toBe(false);
305+
if (body.success) {
306+
throw new Error('Expected an error response');
307+
}
308+
expect(body.error.code).toBe('CONFLICT');
309+
expect(body.error.message).toBe('User is already invited to this organization');
310+
});
311+
266312
it('hides invitation data for member-only workspaces', async () => {
267313
currentAuthContext = {
268314
...authContext,

apps/web/components/users-workspace.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import type { AuthRole, CreateInvitationInput, CurrentUserDto } from '@acme/shared';
3131

3232
import { authClient } from '@/lib/auth-client';
33+
import { ApiClientError } from '@/lib/api-client';
3334
import { useCreateInvitationMutation, useUsersWorkspaceQuery } from '@/lib/queries';
3435

3536
const getErrorMessage = (error: unknown) =>
@@ -93,13 +94,26 @@ export function UsersWorkspace({
9394
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
9495
event.preventDefault();
9596
setNotice(null);
97+
const submittedInvite = { ...inviteForm };
9698

9799
try {
98-
await createInvitationMutation.mutateAsync(inviteForm);
100+
await createInvitationMutation.mutateAsync(submittedInvite);
99101
setInviteForm({ email: '', role: 'member' });
100-
setNotice(`Invitation queued for ${inviteForm.email}`);
101-
} catch {
102-
// Mutation state drives the error message UI.
102+
setNotice(`Invitation queued for ${submittedInvite.email}`);
103+
} catch (error) {
104+
if (error instanceof ApiClientError && error.code === 'REQUEST_TIMEOUT') {
105+
const refreshedWorkspace = await workspaceQuery.refetch();
106+
const invitationWasCreated = refreshedWorkspace.data?.invitations.some(
107+
(invitation) => invitation.email === submittedInvite.email,
108+
);
109+
110+
if (invitationWasCreated) {
111+
setInviteForm({ email: '', role: 'member' });
112+
setNotice(
113+
`Invitation queued for ${submittedInvite.email}. Delivery took longer than the browser wait, but the pending invite was created successfully.`,
114+
);
115+
}
116+
}
103117
}
104118
};
105119

apps/web/lib/api-client.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class ApiClientError extends Error {
2323
}
2424

2525
const REQUEST_TIMEOUT_MS = 8_000;
26+
const INVITATION_REQUEST_TIMEOUT_MS = 20_000;
2627

2728
const parseApiResponse = async (response: Response): Promise<ApiResponse<unknown> | undefined> => {
2829
const text = await response.text();
@@ -38,9 +39,18 @@ const parseApiResponse = async (response: Response): Promise<ApiResponse<unknown
3839
}
3940
};
4041

41-
const request = async <T>(path: string, init: RequestInit, schema: z.ZodType<T>): Promise<T> => {
42+
const request = async <T>(
43+
path: string,
44+
init: RequestInit,
45+
schema: z.ZodType<T>,
46+
options?: {
47+
timeoutMs?: number;
48+
timeoutMessage?: string;
49+
},
50+
): Promise<T> => {
51+
const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
4252
const controller = new AbortController();
43-
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
53+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
4454

4555
try {
4656
const response = await fetch(path, {
@@ -74,7 +84,8 @@ const request = async <T>(path: string, init: RequestInit, schema: z.ZodType<T>)
7484
} catch (error) {
7585
if (error instanceof DOMException && error.name === 'AbortError') {
7686
throw new ApiClientError(
77-
`Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. Confirm the web API proxy is configured and the upstream API is reachable.`,
87+
options?.timeoutMessage ??
88+
`Request timed out after ${timeoutMs / 1000}s. Confirm the web API proxy is configured and the upstream API is reachable.`,
7889
504,
7990
'REQUEST_TIMEOUT',
8091
);
@@ -101,5 +112,10 @@ export const apiClient = {
101112
z.object({
102113
invitationId: z.uuid(),
103114
}),
115+
{
116+
timeoutMs: INVITATION_REQUEST_TIMEOUT_MS,
117+
timeoutMessage:
118+
'Invitation delivery is taking longer than expected. We will check whether the invite was still created before showing an error.',
119+
},
104120
),
105121
};

0 commit comments

Comments
 (0)