Skip to content

Commit fda0aa4

Browse files
feat(invitations): implement invitation creation API with audit logging
1 parent 9f37d11 commit fda0aa4

4 files changed

Lines changed: 246 additions & 8 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { auth, canManageMembers, requireRole } from '@acme/auth';
2+
import { createAuditRepository } from '@acme/db';
3+
import { CreateInvitationInputSchema, success, failure, type CreateInvitationResultDto } from '@acme/shared';
4+
5+
const auditRepository = createAuditRepository();
6+
7+
const isUniqueViolation = (error: unknown): boolean =>
8+
typeof error === 'object' && error !== null && 'code' in error && error.code === '23505';
9+
10+
const isBetterAuthConflict = (
11+
error: unknown,
12+
code: string,
13+
): error is {
14+
message?: string;
15+
body?: {
16+
code?: string;
17+
message?: string;
18+
};
19+
} => {
20+
if (typeof error !== 'object' || error === null) {
21+
return false;
22+
}
23+
24+
const candidate = error as {
25+
message?: string;
26+
body?: {
27+
code?: string;
28+
message?: string;
29+
};
30+
};
31+
32+
if (candidate.body?.code === code) {
33+
return true;
34+
}
35+
36+
const normalizedMessage = candidate.message?.toLowerCase();
37+
const normalizedBodyMessage = candidate.body?.message?.toLowerCase();
38+
39+
return Boolean(
40+
normalizedMessage && normalizedBodyMessage && normalizedMessage.includes(normalizedBodyMessage),
41+
);
42+
};
43+
44+
const getClientIpAddress = (headers: Headers): string | null => {
45+
const forwardedFor = headers.get('x-forwarded-for');
46+
47+
if (forwardedFor) {
48+
const [firstAddress] = forwardedFor
49+
.split(',')
50+
.map((candidate) => candidate.trim())
51+
.filter(Boolean);
52+
53+
if (firstAddress) {
54+
return firstAddress;
55+
}
56+
}
57+
58+
return headers.get('cf-connecting-ip') ?? headers.get('x-real-ip') ?? null;
59+
};
60+
61+
const createMeta = (requestId: string) => ({
62+
requestId,
63+
});
64+
65+
const jsonSuccess = (requestId: string, statusCode: number, data: CreateInvitationResultDto) =>
66+
Response.json(success(data, createMeta(requestId)), {
67+
status: statusCode,
68+
headers: {
69+
'x-request-id': requestId,
70+
},
71+
});
72+
73+
const jsonFailure = (
74+
requestId: string,
75+
statusCode: number,
76+
code: 'BAD_REQUEST' | 'CONFLICT' | 'FORBIDDEN' | 'UNAUTHORIZED' | 'VALIDATION_ERROR',
77+
message: string,
78+
details?: unknown,
79+
) =>
80+
Response.json(
81+
failure(
82+
{
83+
code,
84+
message,
85+
details,
86+
},
87+
createMeta(requestId),
88+
),
89+
{
90+
status: statusCode,
91+
headers: {
92+
'x-request-id': requestId,
93+
},
94+
},
95+
);
96+
97+
export const runtime = 'nodejs';
98+
export const dynamic = 'force-dynamic';
99+
100+
export async function POST(request: Request) {
101+
const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID();
102+
const requestHeaders = new Headers(request.headers);
103+
104+
try {
105+
const authContext = await requireRole(requestHeaders, ['owner', 'admin']);
106+
107+
if (!authContext.organizationId || !canManageMembers(authContext.role)) {
108+
return jsonFailure(
109+
requestId,
110+
403,
111+
'FORBIDDEN',
112+
'Only owners and admins can invite teammates into the active organization',
113+
);
114+
}
115+
116+
const body = await request.json();
117+
const payload = CreateInvitationInputSchema.parse(body);
118+
const invitation = await auth.api.createInvitation({
119+
body: {
120+
...payload,
121+
organizationId: authContext.organizationId,
122+
},
123+
headers: requestHeaders,
124+
});
125+
126+
await auditRepository.appendAuditLog({
127+
organizationId: authContext.organizationId,
128+
eventType: 'invitation.created',
129+
actorUserId: authContext.user.id,
130+
actorRole: authContext.role,
131+
targetEmail: payload.email,
132+
targetInvitationId: invitation.id,
133+
requestId,
134+
ipAddress: getClientIpAddress(requestHeaders),
135+
userAgent: requestHeaders.get('user-agent'),
136+
metadata: {
137+
invitedRole: payload.role,
138+
},
139+
});
140+
141+
return jsonSuccess(requestId, 201, {
142+
invitationId: invitation.id,
143+
});
144+
} catch (error) {
145+
if (error instanceof Error && error.name === 'UnauthorizedAuthError') {
146+
return jsonFailure(requestId, 401, 'UNAUTHORIZED', 'Authentication required');
147+
}
148+
149+
if (error instanceof Error && error.name === 'ForbiddenAuthError') {
150+
return jsonFailure(requestId, 403, 'FORBIDDEN', 'You do not have access to this resource');
151+
}
152+
153+
if (error instanceof Error && error.name === 'ZodError') {
154+
return jsonFailure(requestId, 400, 'VALIDATION_ERROR', 'Request payload is invalid');
155+
}
156+
157+
if (isUniqueViolation(error)) {
158+
return jsonFailure(
159+
requestId,
160+
409,
161+
'CONFLICT',
162+
'A pending invitation already exists for that email',
163+
);
164+
}
165+
166+
if (
167+
isBetterAuthConflict(error, 'USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION') ||
168+
isBetterAuthConflict(error, 'USER_ALREADY_MEMBER_OF_ORGANIZATION')
169+
) {
170+
return jsonFailure(
171+
requestId,
172+
409,
173+
'CONFLICT',
174+
error.body?.message ?? 'A pending invitation already exists for that email',
175+
);
176+
}
177+
178+
throw error;
179+
}
180+
}

apps/web/lib/api-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export const apiClient = {
127127
),
128128
createInvitation: (input: CreateInvitationInput) =>
129129
request<CreateInvitationResultDto>(
130-
'/api/v1/invitations',
130+
'/api/invitations',
131131
{
132132
method: 'POST',
133133
body: JSON.stringify(CreateInvitationInputSchema.parse(input)),

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"dependencies": {
1313
"@acme/auth": "workspace:*",
1414
"@acme/config": "workspace:*",
15+
"@acme/db": "workspace:*",
1516
"@acme/shared": "workspace:*",
1617
"@acme/ui": "workspace:*",
1718
"@sentry/nextjs": "^10.48.0",

pnpm-lock.yaml

Lines changed: 64 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)