@shipfox/api-workspaces 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +2 -2
  4. package/dist/config.d.ts +0 -8
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +1 -50
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/entities/invitation.d.ts +1 -0
  9. package/dist/core/entities/invitation.d.ts.map +1 -1
  10. package/dist/core/entities/invitation.js.map +1 -1
  11. package/dist/core/invitations.d.ts +21 -2
  12. package/dist/core/invitations.d.ts.map +1 -1
  13. package/dist/core/invitations.js +49 -12
  14. package/dist/core/invitations.js.map +1 -1
  15. package/dist/core/workspaces.d.ts +1 -1
  16. package/dist/core/workspaces.d.ts.map +1 -1
  17. package/dist/core/workspaces.js +13 -1
  18. package/dist/core/workspaces.js.map +1 -1
  19. package/dist/db/db.d.ts +34 -0
  20. package/dist/db/db.d.ts.map +1 -1
  21. package/dist/db/index.d.ts +2 -2
  22. package/dist/db/index.d.ts.map +1 -1
  23. package/dist/db/index.js +1 -1
  24. package/dist/db/index.js.map +1 -1
  25. package/dist/db/invitations.d.ts +15 -8
  26. package/dist/db/invitations.d.ts.map +1 -1
  27. package/dist/db/invitations.js +70 -22
  28. package/dist/db/invitations.js.map +1 -1
  29. package/dist/db/schema/invitations.d.ts +17 -0
  30. package/dist/db/schema/invitations.d.ts.map +1 -1
  31. package/dist/db/schema/invitations.js +4 -0
  32. package/dist/db/schema/invitations.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +6 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/presentation/inter-module.d.ts +4 -0
  38. package/dist/presentation/inter-module.d.ts.map +1 -0
  39. package/dist/presentation/inter-module.js +74 -0
  40. package/dist/presentation/inter-module.js.map +1 -0
  41. package/dist/presentation/subscribers/on-invitation-send-requested.js +1 -1
  42. package/dist/presentation/subscribers/on-invitation-send-requested.js.map +1 -1
  43. package/dist/tsconfig.test.tsbuildinfo +1 -1
  44. package/drizzle/0004_tired_talon.sql +1 -0
  45. package/drizzle/meta/0004_snapshot.json +448 -0
  46. package/drizzle/meta/_journal.json +7 -0
  47. package/package.json +17 -9
  48. package/src/config.ts +1 -44
  49. package/src/core/entities/invitation.ts +1 -0
  50. package/src/core/invitations.test.ts +35 -2
  51. package/src/core/invitations.ts +72 -17
  52. package/src/core/workspaces.test.ts +28 -0
  53. package/src/core/workspaces.ts +19 -2
  54. package/src/db/index.ts +1 -6
  55. package/src/db/invitations.test.ts +237 -15
  56. package/src/db/invitations.ts +78 -50
  57. package/src/db/schema/invitations.ts +2 -0
  58. package/src/db/workspaces.test.ts +3 -2
  59. package/src/index.test.ts +10 -1
  60. package/src/index.ts +8 -1
  61. package/src/presentation/inter-module.ts +94 -0
  62. package/src/presentation/routes/invitations/create.test.ts +19 -1
  63. package/src/presentation/subscribers/invitation-send-requested.test.ts +1 -1
  64. package/src/presentation/subscribers/on-invitation-send-requested.ts +1 -1
  65. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,17 +1,18 @@
1
1
  import type {UserContextMembership} from '@shipfox/api-auth-context';
2
+ import {emailSchema} from '@shipfox/api-common-dto';
2
3
  import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
3
4
  import {config} from '#config.js';
4
5
  import {
5
- type AcceptInvitationResult,
6
- acceptInvitation,
7
6
  createInvitation,
8
7
  findInvitationById,
9
8
  findInvitationByToken,
10
9
  listOpenInvitationsByWorkspace,
10
+ reconcileInvitationAcceptance,
11
11
  revokeInvitation,
12
12
  } from '#db/invitations.js';
13
13
  import {getWorkspaceById} from '#db/workspaces.js';
14
14
  import type {Invitation} from './entities/invitation.js';
15
+ import type {Membership} from './entities/membership.js';
15
16
  import {
16
17
  InvitationEmailMismatchError,
17
18
  InvitationNotFoundError,
@@ -40,6 +41,7 @@ export async function createWorkspaceInvitation(params: {
40
41
  invitedByDisplay?: string | null;
41
42
  invitedByMemberships: ReadonlyArray<UserContextMembership>;
42
43
  }): Promise<Invitation> {
44
+ const email = emailSchema.parse(params.email);
43
45
  const {workspace} = await requireWorkspaceMembership({
44
46
  workspaceId: params.workspaceId,
45
47
  userId: params.invitedByUserId,
@@ -49,7 +51,7 @@ export async function createWorkspaceInvitation(params: {
49
51
  const rawToken = generateOpaqueToken('invitation');
50
52
  const invitation = await createInvitation({
51
53
  workspaceId: params.workspaceId,
52
- email: params.email,
54
+ email,
53
55
  hashedToken: hashOpaqueToken(rawToken),
54
56
  expiresAt: daysFromNow(INVITATION_TTL_DAYS),
55
57
  invitedByUserId: params.invitedByUserId,
@@ -83,6 +85,12 @@ export type PreviewInvitationResult =
83
85
  | {status: 'already_used'; workspaceName: string}
84
86
  | {status: 'invalid'};
85
87
 
88
+ export interface AcceptedWorkspaceInvitation {
89
+ invitation: Invitation;
90
+ membership: Membership;
91
+ alreadyMember: boolean;
92
+ }
93
+
86
94
  export async function previewInvitation(params: {token: string}): Promise<PreviewInvitationResult> {
87
95
  const invitation = await peekInvitationByRawToken({token: params.token});
88
96
  if (!invitation) {
@@ -95,6 +103,10 @@ export async function previewInvitation(params: {token: string}): Promise<Previe
95
103
  return {status: 'invalid'};
96
104
  }
97
105
 
106
+ if (invitation.revokedAt !== null) {
107
+ return {status: 'invalid'};
108
+ }
109
+
98
110
  if (invitation.acceptedAt !== null) {
99
111
  return {status: 'already_used', workspaceName: workspace.name};
100
112
  }
@@ -122,33 +134,76 @@ export async function acceptWorkspaceInvitation(params: {
122
134
  userId: string;
123
135
  email: string;
124
136
  name?: string | null | undefined;
125
- }): Promise<AcceptInvitationResult> {
126
- const invitation = await peekInvitationByRawToken({token: params.token});
127
- if (!invitation) {
128
- throw new TokenInvalidError('Invitation token is invalid');
137
+ }): Promise<AcceptedWorkspaceInvitation> {
138
+ const result = await reconcileWorkspaceInvitationAcceptance({
139
+ token: params.token,
140
+ email: params.email,
141
+ userId: params.userId,
142
+ name: params.name,
143
+ });
144
+ if (result.status === 'accepted') {
145
+ return {
146
+ invitation: result.invitation,
147
+ membership: result.membership,
148
+ alreadyMember: result.alreadyMember,
149
+ };
129
150
  }
130
-
131
- if (invitation.acceptedAt !== null) {
151
+ if (result.status === 'already_accepted' || result.status === 'consumed_by_another_user') {
132
152
  throw new TokenAlreadyUsedError();
133
153
  }
134
-
135
- if (invitation.expiresAt.getTime() <= Date.now()) {
154
+ if (result.status === 'expired') {
136
155
  throw new TokenExpiredError();
137
156
  }
138
-
139
- if (params.email !== invitation.email) {
157
+ if (result.status === 'email_mismatch') {
140
158
  throw new InvitationEmailMismatchError();
141
159
  }
160
+ throw new TokenInvalidError(
161
+ result.status === 'revoked' ? 'Invitation token has been revoked' : 'Invitation is invalid',
162
+ );
163
+ }
142
164
 
143
- const result = await acceptInvitation({
165
+ export type WorkspaceInvitationReconciliation =
166
+ | {
167
+ status: 'accepted' | 'already_accepted';
168
+ invitation: Invitation;
169
+ membership: Membership;
170
+ alreadyMember: boolean;
171
+ }
172
+ | {
173
+ status: 'invalid' | 'expired' | 'revoked' | 'consumed_by_another_user' | 'email_mismatch';
174
+ };
175
+
176
+ export async function reconcileWorkspaceInvitationAcceptance(params: {
177
+ token: string;
178
+ userId: string;
179
+ email: string;
180
+ name?: string | null | undefined;
181
+ }): Promise<WorkspaceInvitationReconciliation> {
182
+ const invitation = await peekInvitationByRawToken({token: params.token});
183
+ if (!invitation) return {status: 'invalid'};
184
+
185
+ const result = await reconcileInvitationAcceptance({
144
186
  invitationId: invitation.id,
145
187
  acceptedByUserId: params.userId,
188
+ email: emailSchema.parse(params.email),
146
189
  acceptedByUserName: params.name,
147
190
  });
148
- if (!result) {
149
- throw new TokenInvalidError('Invitation is no longer valid');
191
+ if (result.status === 'accepted') {
192
+ return {
193
+ status: result.status,
194
+ invitation: result.invitation,
195
+ membership: result.membership,
196
+ alreadyMember: result.alreadyMember,
197
+ };
198
+ }
199
+ if (result.status === 'already_accepted') {
200
+ return {
201
+ status: result.status,
202
+ invitation: result.invitation,
203
+ membership: result.membership,
204
+ alreadyMember: true,
205
+ };
150
206
  }
151
-
152
207
  return result;
153
208
  }
154
209
 
@@ -1,4 +1,8 @@
1
+ import {WORKSPACES_WORKSPACE_CREATED} from '@shipfox/api-workspaces-dto';
2
+ import {sql} from 'drizzle-orm';
3
+ import {db} from '#db/db.js';
1
4
  import {findMembership} from '#db/memberships.js';
5
+ import {workspacesOutbox} from '#db/schema/outbox.js';
2
6
  import {userFactory} from '#test/index.js';
3
7
  import {MembershipRequiredError, WorkspaceNotFoundError} from './errors.js';
4
8
  import {createWorkspaceForUser, requireWorkspaceMembership} from './workspaces.js';
@@ -19,6 +23,30 @@ describe('workspaces core', () => {
19
23
  expect(membership).toBeDefined();
20
24
  });
21
25
 
26
+ test('createWorkspaceForUser emits a workspace created event in the transaction', async () => {
27
+ const user = userFactory.build();
28
+
29
+ const workspace = await createWorkspaceForUser({
30
+ name: 'Evented Workspace',
31
+ userId: user.userId,
32
+ userEmail: user.email,
33
+ userName: user.name,
34
+ });
35
+ const [event] = await db()
36
+ .select()
37
+ .from(workspacesOutbox)
38
+ .where(sql`${workspacesOutbox.payload}->>'workspaceId' = ${workspace.id}`);
39
+
40
+ expect(event).toMatchObject({
41
+ eventType: WORKSPACES_WORKSPACE_CREATED,
42
+ payload: {
43
+ workspaceId: workspace.id,
44
+ name: workspace.name,
45
+ creatorUserId: user.userId,
46
+ },
47
+ });
48
+ });
49
+
22
50
  test('requireWorkspaceMembership returns the workspace + role when memberships include it', async () => {
23
51
  const user = userFactory.build();
24
52
  const workspace = await createWorkspaceForUser({
@@ -1,5 +1,10 @@
1
1
  import type {UserContextMembership} from '@shipfox/api-auth-context';
2
- import type {WorkspaceRole} from '@shipfox/api-workspaces-dto';
2
+ import {
3
+ WORKSPACES_WORKSPACE_CREATED,
4
+ type WorkspaceRole,
5
+ type WorkspacesEventMap,
6
+ } from '@shipfox/api-workspaces-dto';
7
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
3
8
  import {db} from '#db/db.js';
4
9
  import {
5
10
  findMembership,
@@ -10,6 +15,7 @@ import {
10
15
  removeMembership,
11
16
  } from '#db/memberships.js';
12
17
  import {memberships} from '#db/schema/memberships.js';
18
+ import {workspacesOutbox} from '#db/schema/outbox.js';
13
19
  import {toWorkspace, workspaces} from '#db/schema/workspaces.js';
14
20
  import {getWorkspaceById} from '#db/workspaces.js';
15
21
  import type {Workspace} from './entities/workspace.js';
@@ -68,7 +74,18 @@ export async function createWorkspaceForUser(params: {
68
74
  userName: params.userName ?? null,
69
75
  workspaceId: workspaceRow.id,
70
76
  });
71
- return toWorkspace(workspaceRow);
77
+ const workspace = toWorkspace(workspaceRow);
78
+
79
+ await writeOutboxEvent<WorkspacesEventMap>(tx, workspacesOutbox, {
80
+ type: WORKSPACES_WORKSPACE_CREATED,
81
+ payload: {
82
+ workspaceId: workspace.id,
83
+ name: workspace.name,
84
+ creatorUserId: params.userId,
85
+ },
86
+ });
87
+
88
+ return workspace;
72
89
  });
73
90
  }
74
91
 
package/src/db/index.ts CHANGED
@@ -2,13 +2,8 @@ import {dirname, resolve} from 'node:path';
2
2
  import {fileURLToPath} from 'node:url';
3
3
 
4
4
  export {closeDb, db, schema} from './db.js';
5
- export type {
6
- AcceptInvitationParams,
7
- AcceptInvitationResult,
8
- CreateInvitationParams,
9
- } from './invitations.js';
5
+ export type {CreateInvitationParams} from './invitations.js';
10
6
  export {
11
- acceptInvitation,
12
7
  createInvitation,
13
8
  findInvitationByToken,
14
9
  listOpenInvitationsByWorkspace,
@@ -1,15 +1,18 @@
1
+ import {WORKSPACES_MEMBER_INVITED, WORKSPACES_MEMBER_JOINED} from '@shipfox/api-workspaces-dto';
1
2
  import {hashOpaqueToken} from '@shipfox/node-tokens';
2
- import {eq, sql} from 'drizzle-orm';
3
+ import {and, eq, sql} from 'drizzle-orm';
3
4
  import {OpenInvitationExistsError} from '#core/errors.js';
4
5
  import {db} from './db.js';
5
6
  import {
6
- acceptInvitation,
7
7
  createInvitation,
8
8
  findInvitationByToken,
9
9
  listOpenInvitationsByWorkspace,
10
+ reconcileInvitationAcceptance,
10
11
  revokeInvitation,
11
12
  } from './invitations.js';
12
13
  import {invitations} from './schema/invitations.js';
14
+ import {memberships} from './schema/memberships.js';
15
+ import {workspacesOutbox} from './schema/outbox.js';
13
16
  import {createWorkspace} from './workspaces.js';
14
17
 
15
18
  function emailFor(suffix: string): string {
@@ -39,6 +42,39 @@ describe('invitations db', () => {
39
42
  expect(invitation.acceptedAt).toBeNull();
40
43
  });
41
44
 
45
+ test('writes the member invited event when email delivery is skipped', async () => {
46
+ const inviter = await createUser({email: emailFor('inviter')});
47
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
48
+ const invitedEmail = emailFor('guest');
49
+
50
+ await createInvitation({
51
+ workspaceId: workspace.id,
52
+ email: invitedEmail,
53
+ hashedToken: hashOpaqueToken('inv-event'),
54
+ expiresAt: new Date(Date.now() + 86_400_000),
55
+ invitedByUserId: inviter.userId,
56
+ skipEmail: true,
57
+ });
58
+
59
+ const events = await db()
60
+ .select()
61
+ .from(workspacesOutbox)
62
+ .where(
63
+ and(
64
+ eq(workspacesOutbox.eventType, WORKSPACES_MEMBER_INVITED),
65
+ sql`${workspacesOutbox.payload}->>'workspaceId' = ${workspace.id}`,
66
+ ),
67
+ );
68
+
69
+ expect(events).toHaveLength(1);
70
+ expect(events[0]?.payload).toEqual({
71
+ workspaceId: workspace.id,
72
+ invitedEmail,
73
+ inviterUserId: inviter.userId,
74
+ role: 'admin',
75
+ });
76
+ });
77
+
42
78
  test('rejects when an unexpired open invite already exists', async () => {
43
79
  const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
44
80
  const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
@@ -94,7 +130,7 @@ describe('invitations db', () => {
94
130
  expect(stillThere).toHaveLength(0);
95
131
  });
96
132
 
97
- test('acceptInvitation creates membership + marks accepted (transactional)', async () => {
133
+ test('reconcileInvitationAcceptance creates membership + marks accepted transactionally', async () => {
98
134
  const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
99
135
  const accepter = await createUser({email: emailFor('accepter'), hashedPassword: 'h'});
100
136
  const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
@@ -107,18 +143,40 @@ describe('invitations db', () => {
107
143
  skipEmail: true,
108
144
  });
109
145
 
110
- const result = await acceptInvitation({
146
+ const result = await reconcileInvitationAcceptance({
111
147
  invitationId: inv.id,
112
148
  acceptedByUserId: accepter.userId,
149
+ email: accepter.email,
113
150
  });
114
151
 
115
- expect(result?.invitation.acceptedAt).toBeInstanceOf(Date);
116
- expect(result?.invitation.acceptedByUserId).toBe(accepter.userId);
117
- expect(result?.membership.userId).toBe(accepter.userId);
118
- expect(result?.alreadyMember).toBe(false);
152
+ expect(result.status).toBe('accepted');
153
+ if (result.status === 'accepted') {
154
+ expect(result.invitation.acceptedAt).toBeInstanceOf(Date);
155
+ expect(result.invitation.acceptedByUserId).toBe(accepter.userId);
156
+ expect(result.membership.userId).toBe(accepter.userId);
157
+ expect(result.alreadyMember).toBe(false);
158
+ }
159
+
160
+ const events = await db()
161
+ .select()
162
+ .from(workspacesOutbox)
163
+ .where(
164
+ and(
165
+ eq(workspacesOutbox.eventType, WORKSPACES_MEMBER_JOINED),
166
+ sql`${workspacesOutbox.payload}->>'workspaceId' = ${workspace.id}`,
167
+ ),
168
+ );
169
+
170
+ expect(events).toHaveLength(1);
171
+ expect(events[0]?.payload).toEqual({
172
+ workspaceId: workspace.id,
173
+ userId: accepter.userId,
174
+ email: accepter.email,
175
+ viaInvitation: true,
176
+ });
119
177
  });
120
178
 
121
- test('acceptInvitation is idempotent when caller is already a member', async () => {
179
+ test('reconcileInvitationAcceptance accepts an existing member', async () => {
122
180
  const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
123
181
  const member = await createUser({email: emailFor('member'), hashedPassword: 'h'});
124
182
  const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
@@ -133,13 +191,148 @@ describe('invitations db', () => {
133
191
  skipEmail: true,
134
192
  });
135
193
 
136
- const result = await acceptInvitation({invitationId: inv.id, acceptedByUserId: member.userId});
194
+ const result = await reconcileInvitationAcceptance({
195
+ invitationId: inv.id,
196
+ acceptedByUserId: member.userId,
197
+ email: member.email,
198
+ });
199
+
200
+ expect(result.status).toBe('accepted');
201
+ if (result.status === 'accepted') {
202
+ expect(result.alreadyMember).toBe(true);
203
+ expect(result.membership.id).toBe(existing.id);
204
+ }
205
+ });
206
+
207
+ test('reconciles a retry after the initial acceptance commits', async () => {
208
+ const inviter = await createUser({email: emailFor('inviter')});
209
+ const accepter = await createUser({email: emailFor('accepter')});
210
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
211
+ const invitation = await createInvitation({
212
+ workspaceId: workspace.id,
213
+ email: accepter.email,
214
+ hashedToken: hashOpaqueToken(`retry-${crypto.randomUUID()}`),
215
+ expiresAt: new Date(Date.now() + 86_400_000),
216
+ invitedByUserId: inviter.userId,
217
+ skipEmail: true,
218
+ });
219
+
220
+ await reconcileInvitationAcceptance({
221
+ invitationId: invitation.id,
222
+ acceptedByUserId: accepter.userId,
223
+ email: accepter.email,
224
+ });
225
+ const retry = await reconcileInvitationAcceptance({
226
+ invitationId: invitation.id,
227
+ acceptedByUserId: accepter.userId,
228
+ email: accepter.email,
229
+ });
230
+
231
+ expect(retry.status).toBe('already_accepted');
232
+ if (retry.status === 'already_accepted') {
233
+ expect(retry.membership.userId).toBe(accepter.userId);
234
+ }
235
+ });
236
+
237
+ test('reconciles an accepted invitation after expiry and email changes', async () => {
238
+ const inviter = await createUser({email: emailFor('inviter')});
239
+ const accepter = await createUser({email: emailFor('accepter')});
240
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
241
+ const invitation = await createInvitation({
242
+ workspaceId: workspace.id,
243
+ email: accepter.email,
244
+ hashedToken: hashOpaqueToken(`accepted-expired-${crypto.randomUUID()}`),
245
+ expiresAt: new Date(Date.now() + 86_400_000),
246
+ invitedByUserId: inviter.userId,
247
+ skipEmail: true,
248
+ });
249
+ await reconcileInvitationAcceptance({
250
+ invitationId: invitation.id,
251
+ acceptedByUserId: accepter.userId,
252
+ email: accepter.email,
253
+ });
254
+ await db()
255
+ .update(invitations)
256
+ .set({expiresAt: new Date(Date.now() - 1_000)})
257
+ .where(eq(invitations.id, invitation.id));
258
+
259
+ const retry = await reconcileInvitationAcceptance({
260
+ invitationId: invitation.id,
261
+ acceptedByUserId: accepter.userId,
262
+ email: emailFor('different-address'),
263
+ });
264
+
265
+ expect(retry.status).toBe('already_accepted');
266
+ });
267
+
268
+ test('serializes concurrent acceptance and rejects the other user', async () => {
269
+ const inviter = await createUser({email: emailFor('inviter')});
270
+ const firstUser = await createUser({email: emailFor('first')});
271
+ const secondUser = await createUser({email: emailFor('second')});
272
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
273
+ const invitationEmail = emailFor('guest');
274
+ const invitation = await createInvitation({
275
+ workspaceId: workspace.id,
276
+ email: invitationEmail,
277
+ hashedToken: hashOpaqueToken(`race-${crypto.randomUUID()}`),
278
+ expiresAt: new Date(Date.now() + 86_400_000),
279
+ invitedByUserId: inviter.userId,
280
+ skipEmail: true,
281
+ });
282
+
283
+ const results = await Promise.all([
284
+ reconcileInvitationAcceptance({
285
+ invitationId: invitation.id,
286
+ acceptedByUserId: firstUser.userId,
287
+ email: invitationEmail,
288
+ }),
289
+ reconcileInvitationAcceptance({
290
+ invitationId: invitation.id,
291
+ acceptedByUserId: secondUser.userId,
292
+ email: invitationEmail,
293
+ }),
294
+ ]);
295
+
296
+ expect(results.map((result) => result.status).sort()).toEqual([
297
+ 'accepted',
298
+ 'consumed_by_another_user',
299
+ ]);
300
+ const accepted = results.find((result) => result.status === 'accepted');
301
+ const membershipsForWorkspace = await db()
302
+ .select()
303
+ .from(memberships)
304
+ .where(eq(memberships.workspaceId, workspace.id));
305
+ expect(accepted?.status).toBe('accepted');
306
+ expect(membershipsForWorkspace).toHaveLength(1);
307
+ expect(membershipsForWorkspace[0]?.userId).toBe(
308
+ accepted?.status === 'accepted' ? accepted.invitation.acceptedByUserId : undefined,
309
+ );
310
+ });
311
+
312
+ test('returns revoked without granting membership', async () => {
313
+ const inviter = await createUser({email: emailFor('inviter')});
314
+ const accepter = await createUser({email: emailFor('accepter')});
315
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
316
+ const invitation = await createInvitation({
317
+ workspaceId: workspace.id,
318
+ email: accepter.email,
319
+ hashedToken: hashOpaqueToken(`revoked-${crypto.randomUUID()}`),
320
+ expiresAt: new Date(Date.now() + 86_400_000),
321
+ invitedByUserId: inviter.userId,
322
+ skipEmail: true,
323
+ });
324
+ await revokeInvitation({invitationId: invitation.id});
325
+
326
+ const result = await reconcileInvitationAcceptance({
327
+ invitationId: invitation.id,
328
+ acceptedByUserId: accepter.userId,
329
+ email: accepter.email,
330
+ });
137
331
 
138
- expect(result?.alreadyMember).toBe(true);
139
- expect(result?.membership.id).toBe(existing.id);
332
+ expect(result).toEqual({status: 'revoked'});
140
333
  });
141
334
 
142
- test('acceptInvitation returns undefined when expired', async () => {
335
+ test('reconcileInvitationAcceptance returns expired before granting membership', async () => {
143
336
  const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
144
337
  const accepter = await createUser({email: emailFor('accepter'), hashedPassword: 'h'});
145
338
  const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
@@ -156,12 +349,13 @@ describe('invitations db', () => {
156
349
  .returning();
157
350
  if (!inserted) throw new Error('insert failed');
158
351
 
159
- const result = await acceptInvitation({
352
+ const result = await reconcileInvitationAcceptance({
160
353
  invitationId: inserted.id,
161
354
  acceptedByUserId: accepter.userId,
355
+ email: accepter.email,
162
356
  });
163
357
 
164
- expect(result).toBeUndefined();
358
+ expect(result).toEqual({status: 'expired'});
165
359
  });
166
360
 
167
361
  test('lists only open invitations and revokes them', async () => {
@@ -184,6 +378,34 @@ describe('invitations db', () => {
184
378
  expect(after.some((i) => i.id === inv.id)).toBe(false);
185
379
  });
186
380
 
381
+ test('does not revoke an accepted invitation receipt', async () => {
382
+ const inviter = await createUser({email: emailFor('inviter')});
383
+ const accepter = await createUser({email: emailFor('accepter')});
384
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
385
+ const invitation = await createInvitation({
386
+ workspaceId: workspace.id,
387
+ email: accepter.email,
388
+ hashedToken: hashOpaqueToken(`accepted-revoke-${crypto.randomUUID()}`),
389
+ expiresAt: new Date(Date.now() + 86_400_000),
390
+ invitedByUserId: inviter.userId,
391
+ skipEmail: true,
392
+ });
393
+ await reconcileInvitationAcceptance({
394
+ invitationId: invitation.id,
395
+ acceptedByUserId: accepter.userId,
396
+ email: accepter.email,
397
+ });
398
+
399
+ await revokeInvitation({invitationId: invitation.id});
400
+ const retry = await reconcileInvitationAcceptance({
401
+ invitationId: invitation.id,
402
+ acceptedByUserId: accepter.userId,
403
+ email: accepter.email,
404
+ });
405
+
406
+ expect(retry.status).toBe('already_accepted');
407
+ });
408
+
187
409
  test('findInvitationByToken returns matching row', async () => {
188
410
  const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
189
411
  const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});