@shipfox/api-workspaces 2.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 +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +97 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +58 -0
- package/dist/config.js.map +1 -0
- package/dist/core/entities/index.d.ts +4 -0
- package/dist/core/entities/index.d.ts.map +1 -0
- package/dist/core/entities/index.js +3 -0
- package/dist/core/entities/index.js.map +1 -0
- package/dist/core/entities/invitation.d.ts +14 -0
- package/dist/core/entities/invitation.d.ts.map +1 -0
- package/dist/core/entities/invitation.js +3 -0
- package/dist/core/entities/invitation.js.map +1 -0
- package/dist/core/entities/membership.d.ts +10 -0
- package/dist/core/entities/membership.d.ts.map +1 -0
- package/dist/core/entities/membership.js +3 -0
- package/dist/core/entities/membership.js.map +1 -0
- package/dist/core/entities/workspace.d.ts +10 -0
- package/dist/core/entities/workspace.d.ts.map +1 -0
- package/dist/core/entities/workspace.js +3 -0
- package/dist/core/entities/workspace.js.map +1 -0
- package/dist/core/errors.d.ts +52 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +104 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +6 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/invitations.d.ts +51 -0
- package/dist/core/invitations.d.ts.map +1 -0
- package/dist/core/invitations.js +136 -0
- package/dist/core/invitations.js.map +1 -0
- package/dist/core/memberships.d.ts +9 -0
- package/dist/core/memberships.d.ts.map +1 -0
- package/dist/core/memberships.js +13 -0
- package/dist/core/memberships.js.map +1 -0
- package/dist/core/workspaces.d.ts +40 -0
- package/dist/core/workspaces.d.ts.map +1 -0
- package/dist/core/workspaces.js +85 -0
- package/dist/core/workspaces.js.map +1 -0
- package/dist/db/db.d.ts +1253 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +24 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +10 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/invitations.d.ts +47 -0
- package/dist/db/invitations.d.ts.map +1 -0
- package/dist/db/invitations.js +107 -0
- package/dist/db/invitations.js.map +1 -0
- package/dist/db/memberships.d.ts +44 -0
- package/dist/db/memberships.d.ts.map +1 -0
- package/dist/db/memberships.js +75 -0
- package/dist/db/memberships.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/invitations.d.ts +199 -0
- package/dist/db/schema/invitations.d.ts.map +1 -0
- package/dist/db/schema/invitations.js +47 -0
- package/dist/db/schema/invitations.js.map +1 -0
- package/dist/db/schema/memberships.d.ts +131 -0
- package/dist/db/schema/memberships.d.ts.map +1 -0
- package/dist/db/schema/memberships.js +35 -0
- package/dist/db/schema/memberships.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/workspaces.d.ts +115 -0
- package/dist/db/schema/workspaces.d.ts.map +1 -0
- package/dist/db/schema/workspaces.js +32 -0
- package/dist/db/schema/workspaces.js.map +1 -0
- package/dist/db/workspaces.d.ts +20 -0
- package/dist/db/workspaces.d.ts.map +1 -0
- package/dist/db/workspaces.js +53 -0
- package/dist/db/workspaces.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +8 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +41 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +26 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +4 -0
- package/dist/presentation/dto/index.d.ts.map +1 -0
- package/dist/presentation/dto/index.js +5 -0
- package/dist/presentation/dto/index.js.map +1 -0
- package/dist/presentation/dto/invitation.d.ts +4 -0
- package/dist/presentation/dto/invitation.d.ts.map +1 -0
- package/dist/presentation/dto/invitation.js +15 -0
- package/dist/presentation/dto/invitation.js.map +1 -0
- package/dist/presentation/dto/membership.d.ts +5 -0
- package/dist/presentation/dto/membership.d.ts.map +1 -0
- package/dist/presentation/dto/membership.js +23 -0
- package/dist/presentation/dto/membership.js.map +1 -0
- package/dist/presentation/dto/workspace.d.ts +4 -0
- package/dist/presentation/dto/workspace.d.ts.map +1 -0
- package/dist/presentation/dto/workspace.js +12 -0
- package/dist/presentation/dto/workspace.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-invitation.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-invitation.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-invitation.js +39 -0
- package/dist/presentation/e2eRoutes/create-invitation.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-workspace.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-workspace.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-workspace.js +27 -0
- package/dist/presentation/e2eRoutes/create-workspace.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +3 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/index.d.ts +2 -0
- package/dist/presentation/index.d.ts.map +1 -0
- package/dist/presentation/index.js +3 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +23 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/invitations/accept.d.ts +2 -0
- package/dist/presentation/routes/invitations/accept.d.ts.map +1 -0
- package/dist/presentation/routes/invitations/accept.js +67 -0
- package/dist/presentation/routes/invitations/accept.js.map +1 -0
- package/dist/presentation/routes/invitations/create.d.ts +2 -0
- package/dist/presentation/routes/invitations/create.d.ts.map +1 -0
- package/dist/presentation/routes/invitations/create.js +66 -0
- package/dist/presentation/routes/invitations/create.js.map +1 -0
- package/dist/presentation/routes/invitations/index.d.ts +4 -0
- package/dist/presentation/routes/invitations/index.d.ts.map +1 -0
- package/dist/presentation/routes/invitations/index.js +19 -0
- package/dist/presentation/routes/invitations/index.js.map +1 -0
- package/dist/presentation/routes/invitations/list.d.ts +2 -0
- package/dist/presentation/routes/invitations/list.d.ts.map +1 -0
- package/dist/presentation/routes/invitations/list.js +58 -0
- package/dist/presentation/routes/invitations/list.js.map +1 -0
- package/dist/presentation/routes/invitations/preview.d.ts +2 -0
- package/dist/presentation/routes/invitations/preview.d.ts.map +1 -0
- package/dist/presentation/routes/invitations/preview.js +47 -0
- package/dist/presentation/routes/invitations/preview.js.map +1 -0
- package/dist/presentation/routes/invitations/revoke.d.ts +2 -0
- package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -0
- package/dist/presentation/routes/invitations/revoke.js +66 -0
- package/dist/presentation/routes/invitations/revoke.js.map +1 -0
- package/dist/presentation/routes/members/index.d.ts +3 -0
- package/dist/presentation/routes/members/index.d.ts.map +1 -0
- package/dist/presentation/routes/members/index.js +8 -0
- package/dist/presentation/routes/members/index.js.map +1 -0
- package/dist/presentation/routes/members/list.d.ts +2 -0
- package/dist/presentation/routes/members/list.d.ts.map +1 -0
- package/dist/presentation/routes/members/list.js +58 -0
- package/dist/presentation/routes/members/list.js.map +1 -0
- package/dist/presentation/routes/members/remove.d.ts +2 -0
- package/dist/presentation/routes/members/remove.d.ts.map +1 -0
- package/dist/presentation/routes/members/remove.js +71 -0
- package/dist/presentation/routes/members/remove.js.map +1 -0
- package/dist/presentation/routes/workspaces/create.d.ts +2 -0
- package/dist/presentation/routes/workspaces/create.d.ts.map +1 -0
- package/dist/presentation/routes/workspaces/create.js +36 -0
- package/dist/presentation/routes/workspaces/create.js.map +1 -0
- package/dist/presentation/routes/workspaces/index.d.ts +3 -0
- package/dist/presentation/routes/workspaces/index.d.ts.map +1 -0
- package/dist/presentation/routes/workspaces/index.js +4 -0
- package/dist/presentation/routes/workspaces/index.js.map +1 -0
- package/dist/presentation/routes/workspaces/list.d.ts +2 -0
- package/dist/presentation/routes/workspaces/list.d.ts.map +1 -0
- package/dist/presentation/routes/workspaces/list.js +32 -0
- package/dist/presentation/routes/workspaces/list.js.map +1 -0
- package/dist/presentation/subscribers/index.d.ts +2 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -0
- package/dist/presentation/subscribers/index.js +3 -0
- package/dist/presentation/subscribers/index.js.map +1 -0
- package/dist/presentation/subscribers/on-invitation-send-requested.d.ts +3 -0
- package/dist/presentation/subscribers/on-invitation-send-requested.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-invitation-send-requested.js +16 -0
- package/dist/presentation/subscribers/on-invitation-send-requested.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +9 -0
- package/drizzle/0001_memberships_invitations.sql +30 -0
- package/drizzle/0002_membership_user_snapshots.sql +40 -0
- package/drizzle/0003_outgoing_enchantress.sql +16 -0
- package/drizzle/meta/0000_snapshot.json +80 -0
- package/drizzle/meta/0001_snapshot.json +705 -0
- package/drizzle/meta/0003_snapshot.json +442 -0
- package/drizzle/meta/_journal.json +34 -0
- package/drizzle.config.ts +7 -0
- package/package.json +71 -0
- package/src/config.ts +51 -0
- package/src/core/entities/index.ts +3 -0
- package/src/core/entities/invitation.ts +13 -0
- package/src/core/entities/membership.ts +9 -0
- package/src/core/entities/workspace.ts +10 -0
- package/src/core/errors.ts +118 -0
- package/src/core/index.ts +35 -0
- package/src/core/invitations.test.ts +251 -0
- package/src/core/invitations.ts +191 -0
- package/src/core/memberships.test.ts +77 -0
- package/src/core/memberships.ts +17 -0
- package/src/core/workspaces.test.ts +72 -0
- package/src/core/workspaces.ts +126 -0
- package/src/db/db.ts +24 -0
- package/src/db/index.ts +43 -0
- package/src/db/invitations.test.ts +204 -0
- package/src/db/invitations.ts +235 -0
- package/src/db/memberships.test.ts +107 -0
- package/src/db/memberships.ts +139 -0
- package/src/db/schema/common.ts +5 -0
- package/src/db/schema/invitations.ts +47 -0
- package/src/db/schema/memberships.ts +39 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/workspaces.ts +33 -0
- package/src/db/workspaces.test.ts +114 -0
- package/src/db/workspaces.ts +81 -0
- package/src/index.test.ts +25 -0
- package/src/index.ts +43 -0
- package/src/metrics/index.ts +1 -0
- package/src/metrics/instance.test.ts +74 -0
- package/src/metrics/instance.ts +57 -0
- package/src/metrics/service.ts +26 -0
- package/src/presentation/dto/index.ts +3 -0
- package/src/presentation/dto/invitation.ts +16 -0
- package/src/presentation/dto/membership.ts +27 -0
- package/src/presentation/dto/workspace.ts +13 -0
- package/src/presentation/e2eRoutes/create-invitation.ts +44 -0
- package/src/presentation/e2eRoutes/create-workspace.ts +30 -0
- package/src/presentation/e2eRoutes/index.ts +8 -0
- package/src/presentation/index.ts +1 -0
- package/src/presentation/routes/index.ts +23 -0
- package/src/presentation/routes/invitations/accept.test.ts +154 -0
- package/src/presentation/routes/invitations/accept.ts +70 -0
- package/src/presentation/routes/invitations/create.test.ts +99 -0
- package/src/presentation/routes/invitations/create.ts +60 -0
- package/src/presentation/routes/invitations/index.ts +17 -0
- package/src/presentation/routes/invitations/list.test.ts +94 -0
- package/src/presentation/routes/invitations/list.ts +53 -0
- package/src/presentation/routes/invitations/preview.test.ts +140 -0
- package/src/presentation/routes/invitations/preview.ts +46 -0
- package/src/presentation/routes/invitations/revoke.test.ts +99 -0
- package/src/presentation/routes/invitations/revoke.ts +64 -0
- package/src/presentation/routes/members/index.ts +5 -0
- package/src/presentation/routes/members/list.test.ts +95 -0
- package/src/presentation/routes/members/list.ts +53 -0
- package/src/presentation/routes/members/remove.test.ts +110 -0
- package/src/presentation/routes/members/remove.ts +66 -0
- package/src/presentation/routes/workspaces/create.test.ts +87 -0
- package/src/presentation/routes/workspaces/create.ts +36 -0
- package/src/presentation/routes/workspaces/index.ts +2 -0
- package/src/presentation/routes/workspaces/list.test.ts +56 -0
- package/src/presentation/routes/workspaces/list.ts +29 -0
- package/src/presentation/subscribers/index.ts +1 -0
- package/src/presentation/subscribers/invitation-send-requested.test.ts +64 -0
- package/src/presentation/subscribers/on-invitation-send-requested.ts +15 -0
- package/test/env.ts +8 -0
- package/test/factories/index.ts +3 -0
- package/test/factories/membership.ts +24 -0
- package/test/factories/user.ts +15 -0
- package/test/factories/workspace.ts +18 -0
- package/test/globalSetup.ts +15 -0
- package/test/index.ts +3 -0
- package/test/routes.ts +255 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {listMembersResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
3
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
|
+
import {z} from 'zod';
|
|
5
|
+
import {
|
|
6
|
+
MembershipRequiredError,
|
|
7
|
+
WorkspaceInactiveError,
|
|
8
|
+
WorkspaceNotFoundError,
|
|
9
|
+
} from '#core/errors.js';
|
|
10
|
+
import {listWorkspaceMembers} from '#core/index.js';
|
|
11
|
+
import {toMembershipWithUserDto} from '#presentation/dto/index.js';
|
|
12
|
+
|
|
13
|
+
export const listMembersRoute = defineRoute({
|
|
14
|
+
method: 'GET',
|
|
15
|
+
path: '/',
|
|
16
|
+
description: 'List the members of a workspace.',
|
|
17
|
+
auth: AUTH_USER,
|
|
18
|
+
schema: {
|
|
19
|
+
params: z.object({workspaceId: z.string().uuid()}),
|
|
20
|
+
response: {
|
|
21
|
+
200: listMembersResponseSchema,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
errorHandler: (error) => {
|
|
25
|
+
if (error instanceof WorkspaceNotFoundError) {
|
|
26
|
+
throw new ClientError('Workspace not found', 'not-found', {status: 404});
|
|
27
|
+
}
|
|
28
|
+
if (error instanceof MembershipRequiredError) {
|
|
29
|
+
throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});
|
|
30
|
+
}
|
|
31
|
+
if (error instanceof WorkspaceInactiveError) {
|
|
32
|
+
throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
},
|
|
36
|
+
handler: async (request) => {
|
|
37
|
+
const client = getUserContext(request);
|
|
38
|
+
if (!client) {
|
|
39
|
+
throw new ClientError('Authentication required', 'unauthorized', {status: 401});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const {workspaceId} = request.params;
|
|
43
|
+
const members = await listWorkspaceMembers({
|
|
44
|
+
workspaceId,
|
|
45
|
+
requesterUserId: client.userId,
|
|
46
|
+
requesterMemberships: client.memberships,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
members: members.map(toMembershipWithUserDto),
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {FastifyInstance} from 'fastify';
|
|
2
|
+
import {
|
|
3
|
+
createInvite,
|
|
4
|
+
createWorkspace,
|
|
5
|
+
createWorkspacesTestApp,
|
|
6
|
+
resetCapturedMail,
|
|
7
|
+
signupVerifyLogin,
|
|
8
|
+
} from '#test/routes.js';
|
|
9
|
+
|
|
10
|
+
describe('DELETE /workspaces/:workspaceId/members/:userId', () => {
|
|
11
|
+
let app: FastifyInstance;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
app = await createWorkspacesTestApp();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
resetCapturedMail();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
await app.close();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('returns 204 and removes a non-owner member', async () => {
|
|
26
|
+
const owner = await signupVerifyLogin(app, 'members-remove-owner');
|
|
27
|
+
const guest = await signupVerifyLogin(app, 'members-remove-guest');
|
|
28
|
+
const workspaceId = await createWorkspace(app, owner.token);
|
|
29
|
+
const invite = await createInvite(app, {token: owner.token, workspaceId, email: guest.email});
|
|
30
|
+
await app.inject({
|
|
31
|
+
method: 'POST',
|
|
32
|
+
url: '/invitations/accept',
|
|
33
|
+
headers: {authorization: `Bearer ${guest.token}`},
|
|
34
|
+
payload: {token: invite.rawToken},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const remove = await app.inject({
|
|
38
|
+
method: 'DELETE',
|
|
39
|
+
url: `/workspaces/${workspaceId}/members/${guest.userId}`,
|
|
40
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
41
|
+
});
|
|
42
|
+
const members = await app.inject({
|
|
43
|
+
method: 'GET',
|
|
44
|
+
url: `/workspaces/${workspaceId}/members`,
|
|
45
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(remove.statusCode).toBe(204);
|
|
49
|
+
expect(members.statusCode).toBe(200);
|
|
50
|
+
expect(members.json().members).toHaveLength(1);
|
|
51
|
+
expect(members.json().members[0].user_id).toBe(owner.userId);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('transforms missing member into 404', async () => {
|
|
55
|
+
const owner = await signupVerifyLogin(app, 'members-remove-missing');
|
|
56
|
+
const workspaceId = await createWorkspace(app, owner.token);
|
|
57
|
+
|
|
58
|
+
const res = await app.inject({
|
|
59
|
+
method: 'DELETE',
|
|
60
|
+
url: `/workspaces/${workspaceId}/members/${crypto.randomUUID()}`,
|
|
61
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(res.statusCode).toBe(404);
|
|
65
|
+
expect(res.json().code).toBe('not-found');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('transforms removing yourself into 409 self-removal-not-allowed', async () => {
|
|
69
|
+
const owner = await signupVerifyLogin(app, 'members-remove-self');
|
|
70
|
+
const workspaceId = await createWorkspace(app, owner.token);
|
|
71
|
+
|
|
72
|
+
const res = await app.inject({
|
|
73
|
+
method: 'DELETE',
|
|
74
|
+
url: `/workspaces/${workspaceId}/members/${owner.userId}`,
|
|
75
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(res.statusCode).toBe(409);
|
|
79
|
+
expect(res.json().code).toBe('self-removal-not-allowed');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('transforms missing membership into 403', async () => {
|
|
83
|
+
const outsider = await signupVerifyLogin(app, 'members-remove-outsider');
|
|
84
|
+
const workspaceId = crypto.randomUUID();
|
|
85
|
+
const userId = crypto.randomUUID();
|
|
86
|
+
|
|
87
|
+
const res = await app.inject({
|
|
88
|
+
method: 'DELETE',
|
|
89
|
+
url: `/workspaces/${workspaceId}/members/${userId}`,
|
|
90
|
+
headers: {authorization: `Bearer ${outsider.token}`},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(res.statusCode).toBe(403);
|
|
94
|
+
expect(res.json().code).toBe('forbidden');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('checks workspace membership before self-removal', async () => {
|
|
98
|
+
const outsider = await signupVerifyLogin(app, 'members-remove-self-outsider');
|
|
99
|
+
const workspaceId = crypto.randomUUID();
|
|
100
|
+
|
|
101
|
+
const res = await app.inject({
|
|
102
|
+
method: 'DELETE',
|
|
103
|
+
url: `/workspaces/${workspaceId}/members/${outsider.userId}`,
|
|
104
|
+
headers: {authorization: `Bearer ${outsider.token}`},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(res.statusCode).toBe(403);
|
|
108
|
+
expect(res.json().code).toBe('forbidden');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
3
|
+
import {z} from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
LastMemberError,
|
|
6
|
+
MembershipNotFoundError,
|
|
7
|
+
MembershipRequiredError,
|
|
8
|
+
SelfRemovalNotAllowedError,
|
|
9
|
+
WorkspaceInactiveError,
|
|
10
|
+
WorkspaceNotFoundError,
|
|
11
|
+
} from '#core/errors.js';
|
|
12
|
+
import {removeWorkspaceMember} from '#core/index.js';
|
|
13
|
+
|
|
14
|
+
export const removeMemberRoute = defineRoute({
|
|
15
|
+
method: 'DELETE',
|
|
16
|
+
path: '/:userId',
|
|
17
|
+
description: 'Remove a member from a workspace.',
|
|
18
|
+
auth: AUTH_USER,
|
|
19
|
+
schema: {
|
|
20
|
+
params: z.object({
|
|
21
|
+
workspaceId: z.string().uuid(),
|
|
22
|
+
userId: z.string().uuid(),
|
|
23
|
+
}),
|
|
24
|
+
response: {
|
|
25
|
+
204: z.void(),
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
errorHandler: (error) => {
|
|
29
|
+
if (error instanceof WorkspaceNotFoundError) {
|
|
30
|
+
throw new ClientError('Workspace not found', 'not-found', {status: 404});
|
|
31
|
+
}
|
|
32
|
+
if (error instanceof MembershipRequiredError) {
|
|
33
|
+
throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});
|
|
34
|
+
}
|
|
35
|
+
if (error instanceof MembershipNotFoundError) {
|
|
36
|
+
throw new ClientError('Member not found', 'not-found', {status: 404});
|
|
37
|
+
}
|
|
38
|
+
if (error instanceof LastMemberError) {
|
|
39
|
+
throw new ClientError(error.message, 'last-member', {status: 409});
|
|
40
|
+
}
|
|
41
|
+
if (error instanceof SelfRemovalNotAllowedError) {
|
|
42
|
+
throw new ClientError(error.message, 'self-removal-not-allowed', {status: 409});
|
|
43
|
+
}
|
|
44
|
+
if (error instanceof WorkspaceInactiveError) {
|
|
45
|
+
throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
},
|
|
49
|
+
handler: async (request, reply) => {
|
|
50
|
+
const client = getUserContext(request);
|
|
51
|
+
if (!client) {
|
|
52
|
+
throw new ClientError('Authentication required', 'unauthorized', {status: 401});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const {workspaceId, userId} = request.params;
|
|
56
|
+
|
|
57
|
+
await removeWorkspaceMember({
|
|
58
|
+
workspaceId,
|
|
59
|
+
userId,
|
|
60
|
+
requesterUserId: client.userId,
|
|
61
|
+
requesterMemberships: client.memberships,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
reply.code(204);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {buildUserContext, setUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import type {FastifyInstance} from 'fastify';
|
|
3
|
+
import Fastify from 'fastify';
|
|
4
|
+
import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
|
|
5
|
+
import {findMembership} from '#db/memberships.js';
|
|
6
|
+
import {createWorkspaceRoute} from './create.js';
|
|
7
|
+
|
|
8
|
+
function emailFor(suffix: string): string {
|
|
9
|
+
return `${suffix}-${crypto.randomUUID()}@example.com`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function createUser(params: {email: string; hashedPassword?: string; name?: string}) {
|
|
13
|
+
await Promise.resolve();
|
|
14
|
+
return {userId: crypto.randomUUID(), email: params.email};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('POST /workspaces', () => {
|
|
18
|
+
let app: FastifyInstance;
|
|
19
|
+
let userId: string;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
app = Fastify();
|
|
23
|
+
app.setValidatorCompiler(validatorCompiler);
|
|
24
|
+
app.setSerializerCompiler(serializerCompiler);
|
|
25
|
+
app.addHook('onRequest', (request, _reply, done) => {
|
|
26
|
+
setUserContext(
|
|
27
|
+
request,
|
|
28
|
+
buildUserContext({
|
|
29
|
+
userId,
|
|
30
|
+
email: 'caller@example.com',
|
|
31
|
+
name: 'Caller Person',
|
|
32
|
+
memberships: [],
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
done();
|
|
36
|
+
});
|
|
37
|
+
app.post('/workspaces', createWorkspaceRoute);
|
|
38
|
+
await app.ready();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
const user = await createUser({email: emailFor('caller'), hashedPassword: 'h'});
|
|
43
|
+
userId = user.userId;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('valid body returns 201 with workspace and creates membership', async () => {
|
|
47
|
+
const res = await app.inject({
|
|
48
|
+
method: 'POST',
|
|
49
|
+
url: '/workspaces',
|
|
50
|
+
payload: {name: ' Test Workspace '},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(res.statusCode).toBe(201);
|
|
54
|
+
const body = res.json();
|
|
55
|
+
expect(body.id).toBeDefined();
|
|
56
|
+
expect(body.name).toBe('Test Workspace');
|
|
57
|
+
expect(body.status).toBe('active');
|
|
58
|
+
|
|
59
|
+
const membership = await findMembership({userId, workspaceId: body.id});
|
|
60
|
+
expect(membership).toBeDefined();
|
|
61
|
+
expect(membership?.userName).toBe('Caller Person');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test.each([
|
|
65
|
+
['blank after trimming', ' '],
|
|
66
|
+
['with control characters', 'Test\nWorkspace'],
|
|
67
|
+
['with format characters', 'Test\u202eWorkspace'],
|
|
68
|
+
])('invalid body with %s returns 400', async (_case, name) => {
|
|
69
|
+
const res = await app.inject({
|
|
70
|
+
method: 'POST',
|
|
71
|
+
url: '/workspaces',
|
|
72
|
+
payload: {name},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(res.statusCode).toBe(400);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('missing name returns 400', async () => {
|
|
79
|
+
const res = await app.inject({
|
|
80
|
+
method: 'POST',
|
|
81
|
+
url: '/workspaces',
|
|
82
|
+
payload: {},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(res.statusCode).toBe(400);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {createWorkspaceBodySchema, workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
3
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
|
+
import {createWorkspaceForUser} from '#core/index.js';
|
|
5
|
+
import {toWorkspaceDto} from '#presentation/dto/index.js';
|
|
6
|
+
|
|
7
|
+
export const createWorkspaceRoute = defineRoute({
|
|
8
|
+
method: 'POST',
|
|
9
|
+
path: '/',
|
|
10
|
+
description: 'Create a workspace for the signed-in user.',
|
|
11
|
+
auth: AUTH_USER,
|
|
12
|
+
schema: {
|
|
13
|
+
body: createWorkspaceBodySchema,
|
|
14
|
+
response: {
|
|
15
|
+
201: workspaceResponseSchema,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
handler: async (request, reply) => {
|
|
19
|
+
const client = getUserContext(request);
|
|
20
|
+
if (!client) {
|
|
21
|
+
throw new ClientError('Authentication required', 'unauthorized', {status: 401});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const {name} = request.body;
|
|
25
|
+
|
|
26
|
+
const workspace = await createWorkspaceForUser({
|
|
27
|
+
name,
|
|
28
|
+
userId: client.userId,
|
|
29
|
+
userEmail: client.email,
|
|
30
|
+
userName: client.name,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
reply.code(201);
|
|
34
|
+
return toWorkspaceDto(workspace);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {buildUserContext, setUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import type {FastifyInstance} from 'fastify';
|
|
3
|
+
import Fastify from 'fastify';
|
|
4
|
+
import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
|
|
5
|
+
import {createMembership} from '#db/memberships.js';
|
|
6
|
+
import {createWorkspace} from '#db/workspaces.js';
|
|
7
|
+
import {listUserWorkspacesRoute} from './list.js';
|
|
8
|
+
|
|
9
|
+
describe('GET /workspaces', () => {
|
|
10
|
+
let app: FastifyInstance;
|
|
11
|
+
let userId: string;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
app = Fastify();
|
|
15
|
+
app.setValidatorCompiler(validatorCompiler);
|
|
16
|
+
app.setSerializerCompiler(serializerCompiler);
|
|
17
|
+
app.addHook('onRequest', (request, _reply, done) => {
|
|
18
|
+
setUserContext(
|
|
19
|
+
request,
|
|
20
|
+
buildUserContext({userId, email: 'caller@example.com', memberships: []}),
|
|
21
|
+
);
|
|
22
|
+
done();
|
|
23
|
+
});
|
|
24
|
+
app.get('/workspaces', listUserWorkspacesRoute);
|
|
25
|
+
await app.ready();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
userId = crypto.randomUUID();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('returns the signed-in user workspace memberships', async () => {
|
|
33
|
+
const workspace = await createWorkspace({name: 'Acme'});
|
|
34
|
+
await createMembership({
|
|
35
|
+
userId,
|
|
36
|
+
userEmail: 'caller@example.com',
|
|
37
|
+
workspaceId: workspace.id,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const res = await app.inject({
|
|
41
|
+
method: 'GET',
|
|
42
|
+
url: '/workspaces',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(res.statusCode).toBe(200);
|
|
46
|
+
expect(res.json()).toMatchObject({
|
|
47
|
+
memberships: [
|
|
48
|
+
{
|
|
49
|
+
user_id: userId,
|
|
50
|
+
workspace_id: workspace.id,
|
|
51
|
+
workspace_name: 'Acme',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {listUserWorkspacesResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
3
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
|
+
import {listUserWorkspaceMemberships} from '#core/index.js';
|
|
5
|
+
import {toMembershipWithWorkspaceDto} from '#presentation/dto/index.js';
|
|
6
|
+
|
|
7
|
+
export const listUserWorkspacesRoute = defineRoute({
|
|
8
|
+
method: 'GET',
|
|
9
|
+
path: '/',
|
|
10
|
+
description: 'List workspace memberships for the signed-in user.',
|
|
11
|
+
auth: AUTH_USER,
|
|
12
|
+
schema: {
|
|
13
|
+
response: {
|
|
14
|
+
200: listUserWorkspacesResponseSchema,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
handler: async (request) => {
|
|
18
|
+
const client = getUserContext(request);
|
|
19
|
+
if (!client) {
|
|
20
|
+
throw new ClientError('Authentication required', 'unauthorized', {status: 401});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const memberships = await listUserWorkspaceMemberships({userId: client.userId});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
memberships: memberships.map(toMembershipWithWorkspaceDto),
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {onInvitationSendRequested} from './on-invitation-send-requested.js';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {Mailer, MailMessage} from '@shipfox/node-mailer';
|
|
2
|
+
import {onInvitationSendRequested} from './index.js';
|
|
3
|
+
|
|
4
|
+
const testMailer = vi.hoisted(
|
|
5
|
+
(): {
|
|
6
|
+
captured: MailMessage[];
|
|
7
|
+
send: ReturnType<typeof vi.fn<Mailer['send']>>;
|
|
8
|
+
mailer: Mailer;
|
|
9
|
+
} => {
|
|
10
|
+
const captured: MailMessage[] = [];
|
|
11
|
+
const send = vi.fn<Mailer['send']>((message) => {
|
|
12
|
+
captured.push(message);
|
|
13
|
+
return Promise.resolve();
|
|
14
|
+
});
|
|
15
|
+
return {captured, send, mailer: {send}};
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
vi.mock('#config.js', () => ({
|
|
20
|
+
mailer: testMailer.mailer,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe('workspace invitation email subscriber', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
testMailer.captured.length = 0;
|
|
26
|
+
testMailer.send.mockReset();
|
|
27
|
+
testMailer.send.mockImplementation((message) => {
|
|
28
|
+
testMailer.captured.push(message);
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('sends a rendered workspace invitation message', async () => {
|
|
34
|
+
await onInvitationSendRequested({
|
|
35
|
+
email: 'invitee@example.com',
|
|
36
|
+
workspaceName: 'Acme Ops',
|
|
37
|
+
inviterName: 'Dana Scully',
|
|
38
|
+
inviteLink: 'https://app.example.test/invitations/accept?token=invite-token',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(testMailer.captured).toHaveLength(1);
|
|
42
|
+
expect(testMailer.captured[0]).toMatchObject({
|
|
43
|
+
to: 'invitee@example.com',
|
|
44
|
+
subject: 'Join Acme Ops on Shipfox',
|
|
45
|
+
});
|
|
46
|
+
expect(testMailer.captured[0]?.text).toContain('Dana Scully has invited you');
|
|
47
|
+
expect(testMailer.captured[0]?.text).toContain('/invitations/accept?token=invite-token');
|
|
48
|
+
expect(testMailer.captured[0]?.html).toContain('Dana Scully');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rethrows mailer failures so the outbox dispatcher retries', async () => {
|
|
52
|
+
const failure = new Error('smtp unavailable');
|
|
53
|
+
testMailer.send.mockRejectedValueOnce(failure);
|
|
54
|
+
|
|
55
|
+
const promise = onInvitationSendRequested({
|
|
56
|
+
email: 'invite-failure@example.com',
|
|
57
|
+
workspaceName: 'Acme Ops',
|
|
58
|
+
inviterName: 'Dana Scully',
|
|
59
|
+
inviteLink: 'https://app.example.test/invitations/accept?token=invite-token',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await expect(promise).rejects.toBe(failure);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type {WorkspacesInvitationSendRequestedEvent} from '@shipfox/api-workspaces-dto';
|
|
2
|
+
import {renderEmail} from '@shipfox/node-email';
|
|
3
|
+
import {mailer} from '#config.js';
|
|
4
|
+
|
|
5
|
+
export async function onInvitationSendRequested(
|
|
6
|
+
payload: WorkspacesInvitationSendRequestedEvent,
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
const email = await renderEmail('workspace-invitation', {
|
|
9
|
+
email: payload.email,
|
|
10
|
+
workspaceName: payload.workspaceName,
|
|
11
|
+
inviterName: payload.inviterName,
|
|
12
|
+
inviteLink: payload.inviteLink,
|
|
13
|
+
});
|
|
14
|
+
await mailer.send({to: payload.email, ...email});
|
|
15
|
+
}
|
package/test/env.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
process.env.POSTGRES_HOST ??= 'localhost';
|
|
2
|
+
process.env.POSTGRES_PORT ??= '5432';
|
|
3
|
+
process.env.POSTGRES_USERNAME ??= 'shipfox';
|
|
4
|
+
process.env.POSTGRES_PASSWORD ??= 'password';
|
|
5
|
+
process.env.POSTGRES_DATABASE = 'api_test';
|
|
6
|
+
process.env.POSTGRES_MAX_CONNECTIONS ??= '5';
|
|
7
|
+
process.env.WORKSPACE_JWT_SECRET = 'test-secret';
|
|
8
|
+
process.env.TZ = 'UTC';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {Factory} from 'fishery';
|
|
2
|
+
import type {Membership} from '#core/entities/membership.js';
|
|
3
|
+
import {createMembership} from '#db/memberships.js';
|
|
4
|
+
|
|
5
|
+
export const membershipFactory = Factory.define<Membership>(({onCreate}) => {
|
|
6
|
+
onCreate((membership) =>
|
|
7
|
+
createMembership({
|
|
8
|
+
userId: membership.userId,
|
|
9
|
+
userEmail: membership.userEmail,
|
|
10
|
+
userName: membership.userName,
|
|
11
|
+
workspaceId: membership.workspaceId,
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
id: crypto.randomUUID(),
|
|
17
|
+
userId: crypto.randomUUID(),
|
|
18
|
+
userEmail: `member-${crypto.randomUUID()}@example.com`,
|
|
19
|
+
userName: null,
|
|
20
|
+
workspaceId: crypto.randomUUID(),
|
|
21
|
+
createdAt: new Date(),
|
|
22
|
+
updatedAt: new Date(),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {Factory} from 'fishery';
|
|
2
|
+
|
|
3
|
+
export interface TestUser {
|
|
4
|
+
userId: string;
|
|
5
|
+
email: string;
|
|
6
|
+
name: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const userFactory = Factory.define<TestUser>(({sequence}) => {
|
|
10
|
+
return {
|
|
11
|
+
userId: crypto.randomUUID(),
|
|
12
|
+
email: `user-${sequence}-${crypto.randomUUID()}@example.com`,
|
|
13
|
+
name: `User ${sequence}`,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {Factory} from 'fishery';
|
|
2
|
+
import type {Workspace} from '#core/entities/workspace.js';
|
|
3
|
+
import {createWorkspace} from '#db/workspaces.js';
|
|
4
|
+
|
|
5
|
+
export const workspaceFactory = Factory.define<Workspace>(({sequence, onCreate}) => {
|
|
6
|
+
onCreate((workspace) => {
|
|
7
|
+
return createWorkspace({name: workspace.name});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
id: crypto.randomUUID(),
|
|
12
|
+
name: `Test Workspace ${sequence}`,
|
|
13
|
+
status: 'active',
|
|
14
|
+
settings: {},
|
|
15
|
+
createdAt: new Date(),
|
|
16
|
+
updatedAt: new Date(),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import './env.js';
|
|
2
|
+
import {runMigrations} from '@shipfox/node-drizzle';
|
|
3
|
+
import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
|
|
4
|
+
import {sql} from 'drizzle-orm';
|
|
5
|
+
import {closeDb, db, migrationsPath} from '#db/index.js';
|
|
6
|
+
|
|
7
|
+
export async function setup() {
|
|
8
|
+
createPostgresClient();
|
|
9
|
+
|
|
10
|
+
await runMigrations(db(), migrationsPath, '__drizzle_migrations_workspaces');
|
|
11
|
+
await db().execute(sql`TRUNCATE workspaces_outbox, workspaces CASCADE`);
|
|
12
|
+
|
|
13
|
+
closeDb();
|
|
14
|
+
await closePostgresClient();
|
|
15
|
+
}
|
package/test/index.ts
ADDED