@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,5 +1,7 @@
1
1
  import {
2
2
  WORKSPACES_INVITATION_SEND_REQUESTED,
3
+ WORKSPACES_MEMBER_INVITED,
4
+ WORKSPACES_MEMBER_JOINED,
3
5
  type WorkspacesEventMap,
4
6
  } from '@shipfox/api-workspaces-dto';
5
7
  import {writeOutboxEvent} from '@shipfox/node-outbox';
@@ -49,6 +51,7 @@ export async function createInvitation(params: CreateInvitationParams): Promise<
49
51
  eq(invitations.workspaceId, params.workspaceId),
50
52
  eq(invitations.email, params.email),
51
53
  isNull(invitations.acceptedAt),
54
+ isNull(invitations.revokedAt),
52
55
  lt(invitations.expiresAt, sql`now()`),
53
56
  ),
54
57
  );
@@ -61,6 +64,7 @@ export async function createInvitation(params: CreateInvitationParams): Promise<
61
64
  eq(invitations.workspaceId, params.workspaceId),
62
65
  eq(invitations.email, params.email),
63
66
  isNull(invitations.acceptedAt),
67
+ isNull(invitations.revokedAt),
64
68
  ),
65
69
  )
66
70
  .limit(1);
@@ -83,6 +87,15 @@ export async function createInvitation(params: CreateInvitationParams): Promise<
83
87
 
84
88
  const row = rows[0];
85
89
  if (!row) throw new Error('Insert returned no rows');
90
+ await writeOutboxEvent<WorkspacesEventMap>(tx, workspacesOutbox, {
91
+ type: WORKSPACES_MEMBER_INVITED,
92
+ payload: {
93
+ workspaceId: params.workspaceId,
94
+ invitedEmail: params.email,
95
+ inviterUserId: params.invitedByUserId,
96
+ role: 'admin',
97
+ },
98
+ });
86
99
  if (params.sendEmail) {
87
100
  await writeOutboxEvent<WorkspacesEventMap>(tx, workspacesOutbox, {
88
101
  type: WORKSPACES_INVITATION_SEND_REQUESTED,
@@ -134,6 +147,7 @@ export async function listOpenInvitationsByWorkspace(params: {
134
147
  and(
135
148
  eq(invitations.workspaceId, params.workspaceId),
136
149
  isNull(invitations.acceptedAt),
150
+ isNull(invitations.revokedAt),
137
151
  gt(invitations.expiresAt, sql`now()`),
138
152
  ),
139
153
  );
@@ -142,73 +156,84 @@ export async function listOpenInvitationsByWorkspace(params: {
142
156
  }
143
157
 
144
158
  export async function revokeInvitation(params: {invitationId: string}): Promise<void> {
145
- await db().delete(invitations).where(eq(invitations.id, params.invitationId));
159
+ await db()
160
+ .update(invitations)
161
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
162
+ .where(and(eq(invitations.id, params.invitationId), isNull(invitations.acceptedAt)));
146
163
  }
147
164
 
148
- export interface AcceptInvitationParams {
165
+ export type ReconcileInvitationAcceptanceResult =
166
+ | {status: 'accepted'; invitation: Invitation; membership: Membership; alreadyMember: boolean}
167
+ | {status: 'already_accepted'; invitation: Invitation; membership: Membership}
168
+ | {status: 'invalid' | 'expired' | 'revoked' | 'consumed_by_another_user' | 'email_mismatch'};
169
+
170
+ export async function reconcileInvitationAcceptance(params: {
149
171
  invitationId: string;
150
172
  acceptedByUserId: string;
173
+ email: string;
151
174
  acceptedByUserName?: string | null | undefined;
152
- }
153
-
154
- export interface AcceptInvitationResult {
155
- invitation: Invitation;
156
- membership: Membership;
157
- alreadyMember: boolean;
158
- }
159
-
160
- export async function acceptInvitation(
161
- params: AcceptInvitationParams,
162
- ): Promise<AcceptInvitationResult | undefined> {
175
+ }): Promise<ReconcileInvitationAcceptanceResult> {
163
176
  const result = await db().transaction(async (tx) => {
164
- const inv = await tx
177
+ const findMembership = async (userId: string, workspaceId: string) => {
178
+ const rows = await tx
179
+ .select()
180
+ .from(memberships)
181
+ .where(and(eq(memberships.userId, userId), eq(memberships.workspaceId, workspaceId)))
182
+ .limit(1);
183
+ const row = rows[0];
184
+ return row ? toMembership(row) : undefined;
185
+ };
186
+ const rows = await tx
165
187
  .select()
166
188
  .from(invitations)
167
- .where(
168
- and(
169
- eq(invitations.id, params.invitationId),
170
- isNull(invitations.acceptedAt),
171
- gt(invitations.expiresAt, sql`now()`),
172
- ),
173
- )
174
- .limit(1);
175
-
176
- const invRow = inv[0];
177
- if (!invRow) return undefined;
178
-
179
- const existing = await tx
180
- .select()
181
- .from(memberships)
182
- .where(
183
- and(
184
- eq(memberships.userId, params.acceptedByUserId),
185
- eq(memberships.workspaceId, invRow.workspaceId),
186
- ),
187
- )
188
- .limit(1);
189
+ .where(eq(invitations.id, params.invitationId))
190
+ .limit(1)
191
+ .for('update');
192
+ const row = rows[0];
193
+ if (!row) return {status: 'invalid'} as const;
189
194
 
190
- let membership: Membership;
191
- let alreadyMember = false;
192
- const existingRow = existing[0];
195
+ const invitation = toInvitation(row);
196
+ if (invitation.acceptedAt !== null) {
197
+ if (invitation.acceptedByUserId !== params.acceptedByUserId) {
198
+ return {status: 'consumed_by_another_user'} as const;
199
+ }
200
+ const membership = await findMembership(params.acceptedByUserId, invitation.workspaceId);
201
+ if (!membership) throw new Error('Accepted invitation has no membership');
202
+ return {status: 'already_accepted', invitation, membership} as const;
203
+ }
204
+ if (invitation.revokedAt !== null) return {status: 'revoked'} as const;
205
+ if (invitation.expiresAt.getTime() <= Date.now()) return {status: 'expired'} as const;
206
+ if (invitation.email !== params.email) return {status: 'email_mismatch'} as const;
193
207
 
194
- if (existingRow) {
195
- alreadyMember = true;
196
- membership = toMembership(existingRow);
197
- } else {
208
+ const existingMembership = await findMembership(
209
+ params.acceptedByUserId,
210
+ invitation.workspaceId,
211
+ );
212
+ let membership = existingMembership;
213
+ if (!membership) {
198
214
  const created = await tx
199
215
  .insert(memberships)
200
216
  .values(
201
217
  membershipValues({
202
218
  userId: params.acceptedByUserId,
203
- userEmail: invRow.email,
219
+ userEmail: invitation.email,
204
220
  userName: params.acceptedByUserName ?? null,
205
- workspaceId: invRow.workspaceId,
221
+ workspaceId: invitation.workspaceId,
206
222
  }),
207
223
  )
208
224
  .returning();
209
225
  const createdRow = created[0];
210
226
  if (!createdRow) throw new Error('Insert returned no rows');
211
227
  membership = toMembership(createdRow);
228
+ await writeOutboxEvent<WorkspacesEventMap>(tx, workspacesOutbox, {
229
+ type: WORKSPACES_MEMBER_JOINED,
230
+ payload: {
231
+ workspaceId: invitation.workspaceId,
232
+ userId: params.acceptedByUserId,
233
+ email: invitation.email,
234
+ viaInvitation: true,
235
+ },
236
+ });
212
237
  }
213
238
 
214
239
  const updated = await tx
@@ -220,15 +245,18 @@ export async function acceptInvitation(
220
245
  })
221
246
  .where(eq(invitations.id, params.invitationId))
222
247
  .returning();
223
-
224
248
  const updatedRow = updated[0];
225
249
  if (!updatedRow) throw new Error('Update returned no rows');
226
-
227
- return {invitation: toInvitation(updatedRow), membership, alreadyMember};
250
+ return {
251
+ status: 'accepted',
252
+ invitation: toInvitation(updatedRow),
253
+ membership,
254
+ alreadyMember: existingMembership !== undefined,
255
+ } as const;
228
256
  });
229
257
 
230
- if (result && !result.alreadyMember) recordWorkspaceMembershipChanged('added');
231
- if (result) {
258
+ if (result.status === 'accepted') {
259
+ if (!result.alreadyMember) recordWorkspaceMembershipChanged('added');
232
260
  recordWorkspaceInvitationAccepted(result.alreadyMember ? 'already_member' : 'added');
233
261
  }
234
262
  return result;
@@ -14,6 +14,7 @@ export const invitations = pgTable(
14
14
  email: text('email').notNull(),
15
15
  hashedToken: text('hashed_token').notNull(),
16
16
  expiresAt: timestamp('expires_at', {withTimezone: true}).notNull(),
17
+ revokedAt: timestamp('revoked_at', {withTimezone: true}),
17
18
  acceptedAt: timestamp('accepted_at', {withTimezone: true}),
18
19
  acceptedByUserId: uuid('accepted_by_user_id'),
19
20
  invitedByUserId: uuid('invited_by_user_id').notNull(),
@@ -37,6 +38,7 @@ export function toInvitation(row: InvitationDb): Invitation {
37
38
  email: row.email,
38
39
  hashedToken: row.hashedToken,
39
40
  expiresAt: row.expiresAt,
41
+ revokedAt: row.revokedAt,
40
42
  acceptedAt: row.acceptedAt,
41
43
  acceptedByUserId: row.acceptedByUserId,
42
44
  invitedByUserId: row.invitedByUserId,
@@ -1,4 +1,4 @@
1
- import {acceptInvitation, createInvitation} from './invitations.js';
1
+ import {createInvitation, reconcileInvitationAcceptance} from './invitations.js';
2
2
  import {createMembership} from './memberships.js';
3
3
  import {
4
4
  createWorkspace,
@@ -98,9 +98,10 @@ describe('workspace queries', () => {
98
98
  invitedByUserId: crypto.randomUUID(),
99
99
  skipEmail: true,
100
100
  });
101
- await acceptInvitation({
101
+ await reconcileInvitationAcceptance({
102
102
  invitationId: acceptedInvitation.id,
103
103
  acceptedByUserId: crypto.randomUUID(),
104
+ email: acceptedInvitation.email,
104
105
  });
105
106
 
106
107
  const metrics = await getWorkspaceServiceMetrics();
package/src/index.test.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import {
2
2
  WORKSPACES_INVITATION_SEND_REQUESTED,
3
+ WORKSPACES_MEMBER_INVITED,
4
+ WORKSPACES_MEMBER_JOINED,
5
+ WORKSPACES_WORKSPACE_CREATED,
3
6
  workspacesEventSchemas,
4
7
  } from '@shipfox/api-workspaces-dto';
5
8
  import {workspacesModule} from './index.js';
@@ -8,17 +11,23 @@ vi.mock('#config.js', () => ({
8
11
  config: {
9
12
  CLIENT_BASE_URL: 'https://app.example.test',
10
13
  },
14
+ }));
15
+
16
+ vi.mock('@shipfox/node-mailer', () => ({
11
17
  mailer: {send: vi.fn()},
12
18
  }));
13
19
 
14
20
  describe('workspacesModule', () => {
15
- test('registers workspace invitation outbox publisher and subscriber', () => {
21
+ test('registers workspace outbox publisher and invitation subscriber', () => {
16
22
  const publisher = workspacesModule.publishers?.find((pub) => pub.name === 'workspaces');
17
23
  const events = workspacesModule.subscribers?.map((subscriber) => subscriber.event);
18
24
 
19
25
  expect(publisher?.eventSchemas).toBe(workspacesEventSchemas);
20
26
  expect(Object.keys(publisher?.eventSchemas ?? {})).toEqual([
21
27
  WORKSPACES_INVITATION_SEND_REQUESTED,
28
+ WORKSPACES_WORKSPACE_CREATED,
29
+ WORKSPACES_MEMBER_INVITED,
30
+ WORKSPACES_MEMBER_JOINED,
22
31
  ]);
23
32
  expect(events).toContain(WORKSPACES_INVITATION_SEND_REQUESTED);
24
33
  });
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import {subscriberFactory} from '@shipfox/node-module';
8
8
  import {db, migrationsPath, workspacesOutbox} from '#db/index.js';
9
9
  import {registerWorkspacesServiceMetrics} from '#metrics/index.js';
10
10
  import {workspacesE2eRoutes} from '#presentation/e2eRoutes/index.js';
11
+ import {createWorkspacesInterModulePresentation} from '#presentation/inter-module.js';
11
12
  import {workspacesRoutes} from '#presentation/routes/index.js';
12
13
  import {onInvitationSendRequested} from '#presentation/subscribers/index.js';
13
14
 
@@ -21,7 +22,12 @@ export {
21
22
  TokenInvalidError,
22
23
  WorkspaceNotFoundError,
23
24
  } from '#core/errors.js';
24
- export {acceptWorkspaceInvitation, peekInvitationByRawToken} from '#core/invitations.js';
25
+ export {
26
+ acceptWorkspaceInvitation,
27
+ peekInvitationByRawToken,
28
+ reconcileWorkspaceInvitationAcceptance,
29
+ type WorkspaceInvitationReconciliation,
30
+ } from '#core/invitations.js';
25
31
  export {type EnsureMembershipParams, ensureMembership} from '#core/memberships.js';
26
32
  export {getWorkspace, requireWorkspaceMembership} from '#core/workspaces.js';
27
33
  export {db, migrationsPath} from '#db/index.js';
@@ -40,4 +46,5 @@ export const workspacesModule: ShipfoxModule = {
40
46
  ],
41
47
  subscribers: [subscriber(WORKSPACES_INVITATION_SEND_REQUESTED, onInvitationSendRequested)],
42
48
  metrics: registerWorkspacesServiceMetrics,
49
+ interModulePresentations: [createWorkspacesInterModulePresentation()],
43
50
  };
@@ -0,0 +1,94 @@
1
+ import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
2
+ import {
3
+ createInterModuleKnownError,
4
+ defineInterModulePresentation,
5
+ type InterModulePresentation,
6
+ } from '@shipfox/inter-module';
7
+ import {
8
+ InvitationEmailMismatchError,
9
+ MembershipRequiredError,
10
+ TokenAlreadyUsedError,
11
+ TokenExpiredError,
12
+ TokenInvalidError,
13
+ WorkspaceInactiveError,
14
+ WorkspaceNotFoundError,
15
+ } from '#core/errors.js';
16
+ import {acceptWorkspaceInvitation, peekInvitationByRawToken} from '#core/invitations.js';
17
+ import {requireWorkspaceMembership} from '#core/workspaces.js';
18
+ import {listMembershipsByUser} from '#db/memberships.js';
19
+
20
+ export function createWorkspacesInterModulePresentation(): InterModulePresentation<
21
+ typeof workspacesInterModuleContract
22
+ > {
23
+ return defineInterModulePresentation(workspacesInterModuleContract, {
24
+ listMembershipsForTokenClaims: async ({userId}) => ({
25
+ memberships: (await listMembershipsByUser({userId})).map(({workspaceId}) => ({
26
+ workspaceId,
27
+ role: 'admin' as const,
28
+ })),
29
+ }),
30
+ preflightInvitationAcceptance: async (input) => {
31
+ try {
32
+ const invitation = await peekInvitationByRawToken({token: input.token});
33
+ if (!invitation) throw new TokenInvalidError('Invitation token is invalid');
34
+ if (invitation.acceptedAt !== null) throw new TokenAlreadyUsedError();
35
+ if (invitation.expiresAt.getTime() <= Date.now()) throw new TokenExpiredError();
36
+ if (invitation.email !== input.email) throw new InvitationEmailMismatchError();
37
+ return {};
38
+ } catch (error) {
39
+ throw toInvitationKnownError('preflightInvitationAcceptance', error);
40
+ }
41
+ },
42
+ acceptInvitation: async (input) => {
43
+ try {
44
+ const result = await acceptWorkspaceInvitation(input);
45
+ return {
46
+ membership: {
47
+ id: result.membership.id,
48
+ userId: result.membership.userId,
49
+ workspaceId: result.membership.workspaceId,
50
+ },
51
+ };
52
+ } catch (error) {
53
+ throw toInvitationKnownError('acceptInvitation', error);
54
+ }
55
+ },
56
+ requireActiveMembership: async (input) => {
57
+ try {
58
+ await requireWorkspaceMembership(input);
59
+ return {};
60
+ } catch (error) {
61
+ const method = workspacesInterModuleContract.methods.requireActiveMembership;
62
+ if (error instanceof MembershipRequiredError)
63
+ throw createInterModuleKnownError(method, 'membership-required', {
64
+ workspaceId: input.workspaceId,
65
+ });
66
+ if (error instanceof WorkspaceNotFoundError)
67
+ throw createInterModuleKnownError(method, 'workspace-not-found', {
68
+ workspaceId: input.workspaceId,
69
+ });
70
+ if (error instanceof WorkspaceInactiveError)
71
+ throw createInterModuleKnownError(method, 'workspace-inactive', {
72
+ workspaceId: input.workspaceId,
73
+ });
74
+ throw error;
75
+ }
76
+ },
77
+ });
78
+ }
79
+
80
+ function toInvitationKnownError(
81
+ methodName: 'preflightInvitationAcceptance' | 'acceptInvitation',
82
+ error: unknown,
83
+ ): unknown {
84
+ const method = workspacesInterModuleContract.methods[methodName];
85
+ if (error instanceof TokenInvalidError)
86
+ return createInterModuleKnownError(method, 'invitation-token-invalid', {});
87
+ if (error instanceof TokenAlreadyUsedError)
88
+ return createInterModuleKnownError(method, 'invitation-token-used', {});
89
+ if (error instanceof TokenExpiredError)
90
+ return createInterModuleKnownError(method, 'invitation-token-expired', {});
91
+ if (error instanceof InvitationEmailMismatchError)
92
+ return createInterModuleKnownError(method, 'invitation-email-mismatch', {});
93
+ return error;
94
+ }
@@ -34,7 +34,7 @@ describe('POST /workspaces/:workspaceId/invitations', () => {
34
34
  method: 'POST',
35
35
  url: `/workspaces/${workspaceId}/invitations`,
36
36
  headers: {authorization: `Bearer ${owner.token}`},
37
- payload: {email: inviteeEmail.toUpperCase()},
37
+ payload: {email: ` ${inviteeEmail.toUpperCase()} `},
38
38
  });
39
39
 
40
40
  expect(res.statusCode).toBe(201);
@@ -66,6 +66,24 @@ describe('POST /workspaces/:workspaceId/invitations', () => {
66
66
  expect(await invitationOutboxEventsTo(inviteeEmail)).toHaveLength(1);
67
67
  });
68
68
 
69
+ test('transforms a whitespace/case-equivalent duplicate open invitation into 409', async () => {
70
+ const owner = await signupVerifyLogin(app, 'invite-create-duplicate-equivalent');
71
+ const workspaceId = await createWorkspace(app, owner.token);
72
+ const inviteeEmail = uniqueEmail('duplicate-invite-equivalent');
73
+ await createInvite(app, {token: owner.token, workspaceId, email: inviteeEmail});
74
+
75
+ const res = await app.inject({
76
+ method: 'POST',
77
+ url: `/workspaces/${workspaceId}/invitations`,
78
+ headers: {authorization: `Bearer ${owner.token}`},
79
+ payload: {email: ` ${inviteeEmail.toUpperCase()} `},
80
+ });
81
+
82
+ expect(res.statusCode).toBe(409);
83
+ expect(res.json().code).toBe('open-invitation-exists');
84
+ expect(await invitationOutboxEventsTo(inviteeEmail)).toHaveLength(1);
85
+ });
86
+
69
87
  test('transforms missing membership into 403', async () => {
70
88
  const outsider = await signupVerifyLogin(app, 'invite-create-outsider');
71
89
  const workspaceId = crypto.randomUUID();
@@ -16,7 +16,7 @@ const testMailer = vi.hoisted(
16
16
  },
17
17
  );
18
18
 
19
- vi.mock('#config.js', () => ({
19
+ vi.mock('@shipfox/node-mailer', () => ({
20
20
  mailer: testMailer.mailer,
21
21
  }));
22
22
 
@@ -1,6 +1,6 @@
1
1
  import type {WorkspacesInvitationSendRequestedEvent} from '@shipfox/api-workspaces-dto';
2
2
  import {renderEmail} from '@shipfox/node-email';
3
- import {mailer} from '#config.js';
3
+ import {mailer} from '@shipfox/node-mailer';
4
4
 
5
5
  export async function onInvitationSendRequested(
6
6
  payload: WorkspacesInvitationSendRequestedEvent,