@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +39 -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 +17 -9
- 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/core/invitations.ts
CHANGED
|
@@ -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
|
|
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<
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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 (
|
|
149
|
-
|
|
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({
|
package/src/core/workspaces.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type {UserContextMembership} from '@shipfox/api-auth-context';
|
|
2
|
-
import
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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('
|
|
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
|
|
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
|
|
139
|
-
expect(result?.membership.id).toBe(existing.id);
|
|
332
|
+
expect(result).toEqual({status: 'revoked'});
|
|
140
333
|
});
|
|
141
334
|
|
|
142
|
-
test('
|
|
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
|
|
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).
|
|
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()}`});
|