@shipfox/api-workspaces 5.0.0 → 7.0.1
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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +54 -0
- package/README.md +2 -2
- package/dist/config.d.ts +0 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -50
- package/dist/config.js.map +1 -1
- package/dist/core/entities/invitation.d.ts +1 -0
- package/dist/core/entities/invitation.d.ts.map +1 -1
- package/dist/core/entities/invitation.js.map +1 -1
- package/dist/core/invitations.d.ts +21 -2
- package/dist/core/invitations.d.ts.map +1 -1
- package/dist/core/invitations.js +49 -12
- package/dist/core/invitations.js.map +1 -1
- package/dist/core/workspaces.d.ts +1 -1
- package/dist/core/workspaces.d.ts.map +1 -1
- package/dist/core/workspaces.js +13 -1
- package/dist/core/workspaces.js.map +1 -1
- package/dist/db/db.d.ts +34 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/invitations.d.ts +15 -8
- package/dist/db/invitations.d.ts.map +1 -1
- package/dist/db/invitations.js +70 -22
- package/dist/db/invitations.js.map +1 -1
- package/dist/db/schema/invitations.d.ts +17 -0
- package/dist/db/schema/invitations.d.ts.map +1 -1
- package/dist/db/schema/invitations.js +4 -0
- package/dist/db/schema/invitations.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/presentation/inter-module.d.ts +4 -0
- package/dist/presentation/inter-module.d.ts.map +1 -0
- package/dist/presentation/inter-module.js +74 -0
- package/dist/presentation/inter-module.js.map +1 -0
- package/dist/presentation/subscribers/on-invitation-send-requested.js +1 -1
- package/dist/presentation/subscribers/on-invitation-send-requested.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0004_tired_talon.sql +1 -0
- package/drizzle/meta/0004_snapshot.json +448 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +18 -10
- package/src/config.ts +1 -44
- package/src/core/entities/invitation.ts +1 -0
- package/src/core/invitations.test.ts +35 -2
- package/src/core/invitations.ts +72 -17
- package/src/core/workspaces.test.ts +28 -0
- package/src/core/workspaces.ts +19 -2
- package/src/db/index.ts +1 -6
- package/src/db/invitations.test.ts +237 -15
- package/src/db/invitations.ts +78 -50
- package/src/db/schema/invitations.ts +2 -0
- package/src/db/workspaces.test.ts +3 -2
- package/src/index.test.ts +10 -1
- package/src/index.ts +8 -1
- package/src/presentation/inter-module.ts +94 -0
- package/src/presentation/routes/invitations/create.test.ts +19 -1
- package/src/presentation/subscribers/invitation-send-requested.test.ts +1 -1
- package/src/presentation/subscribers/on-invitation-send-requested.ts +1 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/db/invitations.ts
CHANGED
|
@@ -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()
|
|
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
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
219
|
+
userEmail: invitation.email,
|
|
204
220
|
userName: params.acceptedByUserName ?? null,
|
|
205
|
-
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
|
-
|
|
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
|
|
231
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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();
|
|
@@ -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 '
|
|
3
|
+
import {mailer} from '@shipfox/node-mailer';
|
|
4
4
|
|
|
5
5
|
export async function onInvitationSendRequested(
|
|
6
6
|
payload: WorkspacesInvitationSendRequestedEvent,
|