@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,107 @@
|
|
|
1
|
+
import {LastMemberError} from '#core/errors.js';
|
|
2
|
+
import {
|
|
3
|
+
createMembership,
|
|
4
|
+
findMembership,
|
|
5
|
+
listMembershipsByUser,
|
|
6
|
+
listMembershipsByWorkspace,
|
|
7
|
+
removeMembership,
|
|
8
|
+
} from './memberships.js';
|
|
9
|
+
import {createWorkspace} from './workspaces.js';
|
|
10
|
+
|
|
11
|
+
function emailFor(suffix: string): string {
|
|
12
|
+
return `${suffix}-${crypto.randomUUID()}@example.com`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function createUser(params: {email: string; hashedPassword?: string; name?: string}) {
|
|
16
|
+
await Promise.resolve();
|
|
17
|
+
return {userId: crypto.randomUUID(), email: params.email, name: null};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('memberships db', () => {
|
|
21
|
+
test('creates a membership', async () => {
|
|
22
|
+
const user = await createUser({email: emailFor('m1'), hashedPassword: 'h'});
|
|
23
|
+
const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
|
|
24
|
+
|
|
25
|
+
const membership = await createMembership({userId: user.userId, workspaceId: workspace.id});
|
|
26
|
+
|
|
27
|
+
expect(membership.userId).toBe(user.userId);
|
|
28
|
+
expect(membership.workspaceId).toBe(workspace.id);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('rejects duplicate (user_id, workspace_id)', async () => {
|
|
32
|
+
const user = await createUser({email: emailFor('dup'), hashedPassword: 'h'});
|
|
33
|
+
const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
|
|
34
|
+
await createMembership({userId: user.userId, workspaceId: workspace.id});
|
|
35
|
+
|
|
36
|
+
await expect(
|
|
37
|
+
createMembership({userId: user.userId, workspaceId: workspace.id}),
|
|
38
|
+
).rejects.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('lists memberships by user joined with workspace name', async () => {
|
|
42
|
+
const user = await createUser({email: emailFor('listu'), hashedPassword: 'h'});
|
|
43
|
+
const workspace = await createWorkspace({name: `Listed-${crypto.randomUUID()}`});
|
|
44
|
+
await createMembership({userId: user.userId, workspaceId: workspace.id});
|
|
45
|
+
|
|
46
|
+
const list = await listMembershipsByUser({userId: user.userId});
|
|
47
|
+
|
|
48
|
+
expect(list).toHaveLength(1);
|
|
49
|
+
expect(list[0]?.workspaceName).toBe(workspace.name);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('lists memberships sorted by workspace name ascending', async () => {
|
|
53
|
+
const user = await createUser({email: emailFor('order'), hashedPassword: 'h'});
|
|
54
|
+
const suffix = crypto.randomUUID();
|
|
55
|
+
const charlie = await createWorkspace({name: `order-${suffix}-Charlie`});
|
|
56
|
+
const alpha = await createWorkspace({name: `order-${suffix}-Alpha`});
|
|
57
|
+
const bravo = await createWorkspace({name: `order-${suffix}-Bravo`});
|
|
58
|
+
await createMembership({userId: user.userId, workspaceId: charlie.id});
|
|
59
|
+
await createMembership({userId: user.userId, workspaceId: alpha.id});
|
|
60
|
+
await createMembership({userId: user.userId, workspaceId: bravo.id});
|
|
61
|
+
|
|
62
|
+
const list = await listMembershipsByUser({userId: user.userId});
|
|
63
|
+
|
|
64
|
+
expect(list.map((m) => m.workspaceName)).toEqual([alpha.name, bravo.name, charlie.name]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('lists memberships by workspace joined with user info', async () => {
|
|
68
|
+
const user = await createUser({email: emailFor('listt'), hashedPassword: 'h', name: 'Listy'});
|
|
69
|
+
const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
|
|
70
|
+
await createMembership({
|
|
71
|
+
userId: user.userId,
|
|
72
|
+
userEmail: user.email,
|
|
73
|
+
userName: 'Listy',
|
|
74
|
+
workspaceId: workspace.id,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const list = await listMembershipsByWorkspace({workspaceId: workspace.id});
|
|
78
|
+
|
|
79
|
+
expect(list).toHaveLength(1);
|
|
80
|
+
expect(list[0]?.userEmail).toBe(user.email);
|
|
81
|
+
expect(list[0]?.userName).toBe('Listy');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('removeMembership succeeds when ≥2 members exist', async () => {
|
|
85
|
+
const userA = await createUser({email: emailFor('a'), hashedPassword: 'h'});
|
|
86
|
+
const userB = await createUser({email: emailFor('b'), hashedPassword: 'h'});
|
|
87
|
+
const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
|
|
88
|
+
await createMembership({userId: userA.userId, workspaceId: workspace.id});
|
|
89
|
+
await createMembership({userId: userB.userId, workspaceId: workspace.id});
|
|
90
|
+
|
|
91
|
+
await removeMembership({userId: userA.userId, workspaceId: workspace.id});
|
|
92
|
+
|
|
93
|
+
expect(await findMembership({userId: userA.userId, workspaceId: workspace.id})).toBeUndefined();
|
|
94
|
+
expect(await findMembership({userId: userB.userId, workspaceId: workspace.id})).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('removeMembership rejects when only 1 member exists', async () => {
|
|
98
|
+
const user = await createUser({email: emailFor('only'), hashedPassword: 'h'});
|
|
99
|
+
const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
|
|
100
|
+
await createMembership({userId: user.userId, workspaceId: workspace.id});
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
removeMembership({userId: user.userId, workspaceId: workspace.id}),
|
|
104
|
+
).rejects.toBeInstanceOf(LastMemberError);
|
|
105
|
+
expect(await findMembership({userId: user.userId, workspaceId: workspace.id})).toBeDefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {and, eq, sql} from 'drizzle-orm';
|
|
2
|
+
import type {Membership} from '#core/entities/membership.js';
|
|
3
|
+
import {LastMemberError} from '#core/errors.js';
|
|
4
|
+
import {recordWorkspaceMembershipChanged} from '#metrics/instance.js';
|
|
5
|
+
import {db} from './db.js';
|
|
6
|
+
import {memberships, toMembership} from './schema/memberships.js';
|
|
7
|
+
import {workspaces} from './schema/workspaces.js';
|
|
8
|
+
|
|
9
|
+
export interface CreateMembershipParams {
|
|
10
|
+
userId: string;
|
|
11
|
+
userEmail?: string | undefined;
|
|
12
|
+
userName?: string | null | undefined;
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EnsureMembershipParams {
|
|
17
|
+
userId: string;
|
|
18
|
+
userEmail: string;
|
|
19
|
+
userName: string | null;
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function membershipValues(params: CreateMembershipParams) {
|
|
24
|
+
return {
|
|
25
|
+
userId: params.userId,
|
|
26
|
+
userEmail: params.userEmail ?? `user-${params.userId}@example.local`,
|
|
27
|
+
userName: params.userName ?? null,
|
|
28
|
+
workspaceId: params.workspaceId,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function createMembership(params: CreateMembershipParams): Promise<Membership> {
|
|
33
|
+
const rows = await db().insert(memberships).values(membershipValues(params)).returning();
|
|
34
|
+
|
|
35
|
+
const row = rows[0];
|
|
36
|
+
if (!row) throw new Error('Insert returned no rows');
|
|
37
|
+
recordWorkspaceMembershipChanged('added');
|
|
38
|
+
return toMembership(row);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function ensureMembership(params: EnsureMembershipParams): Promise<Membership> {
|
|
42
|
+
const rows = await db()
|
|
43
|
+
.insert(memberships)
|
|
44
|
+
.values(membershipValues(params))
|
|
45
|
+
.onConflictDoNothing({target: [memberships.userId, memberships.workspaceId]})
|
|
46
|
+
.returning();
|
|
47
|
+
|
|
48
|
+
const row = rows[0];
|
|
49
|
+
if (row) {
|
|
50
|
+
recordWorkspaceMembershipChanged('added');
|
|
51
|
+
return toMembership(row);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const existing = await findMembership(params);
|
|
55
|
+
if (existing) return existing;
|
|
56
|
+
throw new Error('Membership conflict returned no membership');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MembershipWithWorkspace extends Membership {
|
|
60
|
+
workspaceName: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function listMembershipsByUser(params: {
|
|
64
|
+
userId: string;
|
|
65
|
+
}): Promise<MembershipWithWorkspace[]> {
|
|
66
|
+
const rows = await db()
|
|
67
|
+
.select({
|
|
68
|
+
membership: memberships,
|
|
69
|
+
workspaceName: workspaces.name,
|
|
70
|
+
})
|
|
71
|
+
.from(memberships)
|
|
72
|
+
.innerJoin(workspaces, eq(workspaces.id, memberships.workspaceId))
|
|
73
|
+
.where(eq(memberships.userId, params.userId))
|
|
74
|
+
.orderBy(workspaces.name);
|
|
75
|
+
|
|
76
|
+
return rows.map((row) => ({...toMembership(row.membership), workspaceName: row.workspaceName}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MembershipWithUser extends Membership {
|
|
80
|
+
userEmail: string;
|
|
81
|
+
userName: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function listMembershipsByWorkspace(params: {
|
|
85
|
+
workspaceId: string;
|
|
86
|
+
}): Promise<MembershipWithUser[]> {
|
|
87
|
+
const rows = await db()
|
|
88
|
+
.select()
|
|
89
|
+
.from(memberships)
|
|
90
|
+
.where(eq(memberships.workspaceId, params.workspaceId));
|
|
91
|
+
|
|
92
|
+
return rows.map(toMembership);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function findMembership(params: {
|
|
96
|
+
userId: string;
|
|
97
|
+
workspaceId: string;
|
|
98
|
+
}): Promise<Membership | undefined> {
|
|
99
|
+
const rows = await db()
|
|
100
|
+
.select()
|
|
101
|
+
.from(memberships)
|
|
102
|
+
.where(
|
|
103
|
+
and(eq(memberships.userId, params.userId), eq(memberships.workspaceId, params.workspaceId)),
|
|
104
|
+
)
|
|
105
|
+
.limit(1);
|
|
106
|
+
|
|
107
|
+
const row = rows[0];
|
|
108
|
+
if (!row) return undefined;
|
|
109
|
+
return toMembership(row);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RemoveMembershipParams {
|
|
113
|
+
userId: string;
|
|
114
|
+
workspaceId: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function removeMembership(params: RemoveMembershipParams): Promise<void> {
|
|
118
|
+
const removed = await db().transaction(async (tx) => {
|
|
119
|
+
const countResult = await tx
|
|
120
|
+
.select({count: sql<number>`count(*)::int`})
|
|
121
|
+
.from(memberships)
|
|
122
|
+
.where(eq(memberships.workspaceId, params.workspaceId));
|
|
123
|
+
const total = countResult[0]?.count ?? 0;
|
|
124
|
+
if (total <= 1) {
|
|
125
|
+
throw new LastMemberError(params.workspaceId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const deleted = await tx
|
|
129
|
+
.delete(memberships)
|
|
130
|
+
.where(
|
|
131
|
+
and(eq(memberships.userId, params.userId), eq(memberships.workspaceId, params.workspaceId)),
|
|
132
|
+
)
|
|
133
|
+
.returning({id: memberships.id});
|
|
134
|
+
|
|
135
|
+
return deleted.length > 0;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (removed) recordWorkspaceMembershipChanged('removed');
|
|
139
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {index, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import type {Invitation} from '#core/entities/invitation.js';
|
|
4
|
+
import {pgTable} from './common.js';
|
|
5
|
+
import {workspaces} from './workspaces.js';
|
|
6
|
+
|
|
7
|
+
export const invitations = pgTable(
|
|
8
|
+
'invitations',
|
|
9
|
+
{
|
|
10
|
+
id: uuidv7PrimaryKey(),
|
|
11
|
+
workspaceId: uuid('workspace_id')
|
|
12
|
+
.notNull()
|
|
13
|
+
.references(() => workspaces.id, {onDelete: 'cascade'}),
|
|
14
|
+
email: text('email').notNull(),
|
|
15
|
+
hashedToken: text('hashed_token').notNull(),
|
|
16
|
+
expiresAt: timestamp('expires_at', {withTimezone: true}).notNull(),
|
|
17
|
+
acceptedAt: timestamp('accepted_at', {withTimezone: true}),
|
|
18
|
+
acceptedByUserId: uuid('accepted_by_user_id'),
|
|
19
|
+
invitedByUserId: uuid('invited_by_user_id').notNull(),
|
|
20
|
+
invitedByDisplay: text('invited_by_display'),
|
|
21
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
22
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
23
|
+
},
|
|
24
|
+
(table) => [
|
|
25
|
+
uniqueIndex('workspaces_invitations_hashed_token_unique').on(table.hashedToken),
|
|
26
|
+
index('workspaces_invitations_workspace_email_idx').on(table.workspaceId, table.email),
|
|
27
|
+
],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export type InvitationDb = typeof invitations.$inferSelect;
|
|
31
|
+
export type InvitationCreateDb = typeof invitations.$inferInsert;
|
|
32
|
+
|
|
33
|
+
export function toInvitation(row: InvitationDb): Invitation {
|
|
34
|
+
return {
|
|
35
|
+
id: row.id,
|
|
36
|
+
workspaceId: row.workspaceId,
|
|
37
|
+
email: row.email,
|
|
38
|
+
hashedToken: row.hashedToken,
|
|
39
|
+
expiresAt: row.expiresAt,
|
|
40
|
+
acceptedAt: row.acceptedAt,
|
|
41
|
+
acceptedByUserId: row.acceptedByUserId,
|
|
42
|
+
invitedByUserId: row.invitedByUserId,
|
|
43
|
+
invitedByDisplay: row.invitedByDisplay,
|
|
44
|
+
createdAt: row.createdAt,
|
|
45
|
+
updatedAt: row.updatedAt,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {index, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import type {Membership} from '#core/entities/membership.js';
|
|
4
|
+
import {pgTable} from './common.js';
|
|
5
|
+
import {workspaces} from './workspaces.js';
|
|
6
|
+
|
|
7
|
+
export const memberships = pgTable(
|
|
8
|
+
'memberships',
|
|
9
|
+
{
|
|
10
|
+
id: uuidv7PrimaryKey(),
|
|
11
|
+
userId: uuid('user_id').notNull(),
|
|
12
|
+
userEmail: text('user_email').notNull(),
|
|
13
|
+
userName: text('user_name'),
|
|
14
|
+
workspaceId: uuid('workspace_id')
|
|
15
|
+
.notNull()
|
|
16
|
+
.references(() => workspaces.id, {onDelete: 'cascade'}),
|
|
17
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
18
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
19
|
+
},
|
|
20
|
+
(table) => [
|
|
21
|
+
uniqueIndex('workspaces_memberships_user_workspace_unique').on(table.userId, table.workspaceId),
|
|
22
|
+
index('workspaces_memberships_workspace_id_idx').on(table.workspaceId),
|
|
23
|
+
],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export type MembershipDb = typeof memberships.$inferSelect;
|
|
27
|
+
export type MembershipCreateDb = typeof memberships.$inferInsert;
|
|
28
|
+
|
|
29
|
+
export function toMembership(row: MembershipDb): Membership {
|
|
30
|
+
return {
|
|
31
|
+
id: row.id,
|
|
32
|
+
userId: row.userId,
|
|
33
|
+
userEmail: row.userEmail,
|
|
34
|
+
userName: row.userName,
|
|
35
|
+
workspaceId: row.workspaceId,
|
|
36
|
+
createdAt: row.createdAt,
|
|
37
|
+
updatedAt: row.updatedAt,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {jsonb, pgEnum, text, timestamp} from 'drizzle-orm/pg-core';
|
|
3
|
+
import type {Workspace, WorkspaceStatus} from '#core/entities/workspace.js';
|
|
4
|
+
import {pgTable} from './common.js';
|
|
5
|
+
|
|
6
|
+
export const workspaceStatusEnum = pgEnum('workspaces_workspace_status', [
|
|
7
|
+
'active',
|
|
8
|
+
'suspended',
|
|
9
|
+
'deleted',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const workspaces = pgTable('workspaces', {
|
|
13
|
+
id: uuidv7PrimaryKey(),
|
|
14
|
+
name: text('name').notNull(),
|
|
15
|
+
status: workspaceStatusEnum('status').notNull().default('active'),
|
|
16
|
+
settings: jsonb('settings').notNull().default({}),
|
|
17
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
18
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type WorkspaceDb = typeof workspaces.$inferSelect;
|
|
22
|
+
export type WorkspaceCreateDb = typeof workspaces.$inferInsert;
|
|
23
|
+
|
|
24
|
+
export function toWorkspace(row: WorkspaceDb): Workspace {
|
|
25
|
+
return {
|
|
26
|
+
id: row.id,
|
|
27
|
+
name: row.name,
|
|
28
|
+
status: row.status as WorkspaceStatus,
|
|
29
|
+
settings: row.settings as Record<string, unknown>,
|
|
30
|
+
createdAt: row.createdAt,
|
|
31
|
+
updatedAt: row.updatedAt,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {acceptInvitation, createInvitation} from './invitations.js';
|
|
2
|
+
import {createMembership} from './memberships.js';
|
|
3
|
+
import {
|
|
4
|
+
createWorkspace,
|
|
5
|
+
getWorkspaceById,
|
|
6
|
+
getWorkspaceServiceMetrics,
|
|
7
|
+
updateWorkspace,
|
|
8
|
+
} from './workspaces.js';
|
|
9
|
+
|
|
10
|
+
describe('workspace queries', () => {
|
|
11
|
+
describe('createWorkspace', () => {
|
|
12
|
+
test('inserts a workspace with defaults and returns entity', async () => {
|
|
13
|
+
const workspace = await createWorkspace({name: 'Acme'});
|
|
14
|
+
|
|
15
|
+
expect(workspace.id).toBeDefined();
|
|
16
|
+
expect(workspace.name).toBe('Acme');
|
|
17
|
+
expect(workspace.status).toBe('active');
|
|
18
|
+
expect(workspace.settings).toEqual({});
|
|
19
|
+
expect(workspace.createdAt).toBeInstanceOf(Date);
|
|
20
|
+
expect(workspace.updatedAt).toBeInstanceOf(Date);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('getWorkspaceById', () => {
|
|
25
|
+
test('returns the workspace when found', async () => {
|
|
26
|
+
const created = await createWorkspace({name: 'Lookup Test'});
|
|
27
|
+
|
|
28
|
+
const found = await getWorkspaceById(created.id);
|
|
29
|
+
|
|
30
|
+
expect(found).toBeDefined();
|
|
31
|
+
expect(found?.id).toBe(created.id);
|
|
32
|
+
expect(found?.name).toBe('Lookup Test');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('returns undefined when not found', async () => {
|
|
36
|
+
const found = await getWorkspaceById(crypto.randomUUID());
|
|
37
|
+
|
|
38
|
+
expect(found).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('updateWorkspace', () => {
|
|
43
|
+
test('updates only provided fields', async () => {
|
|
44
|
+
const created = await createWorkspace({name: 'Before'});
|
|
45
|
+
|
|
46
|
+
const updated = await updateWorkspace({id: created.id, name: 'After'});
|
|
47
|
+
|
|
48
|
+
expect(updated).toBeDefined();
|
|
49
|
+
expect(updated?.name).toBe('After');
|
|
50
|
+
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('updates status', async () => {
|
|
54
|
+
const created = await createWorkspace({name: 'Status Update'});
|
|
55
|
+
|
|
56
|
+
const updated = await updateWorkspace({id: created.id, status: 'suspended'});
|
|
57
|
+
|
|
58
|
+
expect(updated?.status).toBe('suspended');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('returns undefined for non-existent workspace', async () => {
|
|
62
|
+
const result = await updateWorkspace({id: crypto.randomUUID(), name: 'Ghost'});
|
|
63
|
+
|
|
64
|
+
expect(result).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('getWorkspaceServiceMetrics', () => {
|
|
69
|
+
test('counts current workspace service state', async () => {
|
|
70
|
+
const activeWorkspace = await createWorkspace({
|
|
71
|
+
name: `Metrics Active ${crypto.randomUUID()}`,
|
|
72
|
+
});
|
|
73
|
+
await createMembership({
|
|
74
|
+
userId: crypto.randomUUID(),
|
|
75
|
+
workspaceId: activeWorkspace.id,
|
|
76
|
+
});
|
|
77
|
+
const openInvitation = await createInvitation({
|
|
78
|
+
workspaceId: activeWorkspace.id,
|
|
79
|
+
email: `open-${crypto.randomUUID()}@example.com`,
|
|
80
|
+
hashedToken: `open-${crypto.randomUUID()}`,
|
|
81
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
82
|
+
invitedByUserId: crypto.randomUUID(),
|
|
83
|
+
skipEmail: true,
|
|
84
|
+
});
|
|
85
|
+
const acceptedInvitation = await createInvitation({
|
|
86
|
+
workspaceId: activeWorkspace.id,
|
|
87
|
+
email: `accepted-${crypto.randomUUID()}@example.com`,
|
|
88
|
+
hashedToken: `accepted-${crypto.randomUUID()}`,
|
|
89
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
90
|
+
invitedByUserId: crypto.randomUUID(),
|
|
91
|
+
skipEmail: true,
|
|
92
|
+
});
|
|
93
|
+
await createInvitation({
|
|
94
|
+
workspaceId: activeWorkspace.id,
|
|
95
|
+
email: `expired-${crypto.randomUUID()}@example.com`,
|
|
96
|
+
hashedToken: `expired-${crypto.randomUUID()}`,
|
|
97
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
98
|
+
invitedByUserId: crypto.randomUUID(),
|
|
99
|
+
skipEmail: true,
|
|
100
|
+
});
|
|
101
|
+
await acceptInvitation({
|
|
102
|
+
invitationId: acceptedInvitation.id,
|
|
103
|
+
acceptedByUserId: crypto.randomUUID(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const metrics = await getWorkspaceServiceMetrics();
|
|
107
|
+
|
|
108
|
+
expect(openInvitation.acceptedAt).toBeNull();
|
|
109
|
+
expect(metrics.activeWorkspaces).toBeGreaterThanOrEqual(1);
|
|
110
|
+
expect(metrics.memberships).toBeGreaterThanOrEqual(2);
|
|
111
|
+
expect(metrics.openInvitations).toBeGreaterThanOrEqual(1);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {and, count, eq, gt, isNull, sql} from 'drizzle-orm';
|
|
2
|
+
import type {Workspace, WorkspaceStatus} from '#core/entities/workspace.js';
|
|
3
|
+
import {recordWorkspaceCreated} from '#metrics/instance.js';
|
|
4
|
+
import {db} from './db.js';
|
|
5
|
+
import {invitations} from './schema/invitations.js';
|
|
6
|
+
import {memberships} from './schema/memberships.js';
|
|
7
|
+
import {toWorkspace, workspaces} from './schema/workspaces.js';
|
|
8
|
+
|
|
9
|
+
export interface CreateWorkspaceParams {
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function createWorkspace(params: CreateWorkspaceParams): Promise<Workspace> {
|
|
14
|
+
const rows = await db()
|
|
15
|
+
.insert(workspaces)
|
|
16
|
+
.values({
|
|
17
|
+
name: params.name,
|
|
18
|
+
})
|
|
19
|
+
.returning();
|
|
20
|
+
|
|
21
|
+
const row = rows[0];
|
|
22
|
+
if (!row) throw new Error('Insert returned no rows');
|
|
23
|
+
recordWorkspaceCreated();
|
|
24
|
+
return toWorkspace(row);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UpdateWorkspaceParams {
|
|
28
|
+
id: string;
|
|
29
|
+
name?: string | undefined;
|
|
30
|
+
status?: WorkspaceStatus | undefined;
|
|
31
|
+
settings?: Record<string, unknown> | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function updateWorkspace(
|
|
35
|
+
params: UpdateWorkspaceParams,
|
|
36
|
+
): Promise<Workspace | undefined> {
|
|
37
|
+
const set: Record<string, unknown> = {updatedAt: sql`NOW()`};
|
|
38
|
+
if (params.name !== undefined) set.name = params.name;
|
|
39
|
+
if (params.status !== undefined) set.status = params.status;
|
|
40
|
+
if (params.settings !== undefined) set.settings = params.settings;
|
|
41
|
+
|
|
42
|
+
const rows = await db()
|
|
43
|
+
.update(workspaces)
|
|
44
|
+
.set(set)
|
|
45
|
+
.where(eq(workspaces.id, params.id))
|
|
46
|
+
.returning();
|
|
47
|
+
|
|
48
|
+
const row = rows[0];
|
|
49
|
+
if (!row) return undefined;
|
|
50
|
+
return toWorkspace(row);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getWorkspaceById(id: string): Promise<Workspace | undefined> {
|
|
54
|
+
const rows = await db().select().from(workspaces).where(eq(workspaces.id, id)).limit(1);
|
|
55
|
+
const row = rows[0];
|
|
56
|
+
if (!row) return undefined;
|
|
57
|
+
return toWorkspace(row);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface WorkspaceServiceMetrics {
|
|
61
|
+
activeWorkspaces: number;
|
|
62
|
+
memberships: number;
|
|
63
|
+
openInvitations: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function getWorkspaceServiceMetrics(): Promise<WorkspaceServiceMetrics> {
|
|
67
|
+
const [workspaceRows, membershipRows, invitationRows] = await Promise.all([
|
|
68
|
+
db().select({value: count()}).from(workspaces).where(eq(workspaces.status, 'active')),
|
|
69
|
+
db().select({value: count()}).from(memberships),
|
|
70
|
+
db()
|
|
71
|
+
.select({value: count()})
|
|
72
|
+
.from(invitations)
|
|
73
|
+
.where(and(isNull(invitations.acceptedAt), gt(invitations.expiresAt, sql`now()`))),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
activeWorkspaces: workspaceRows[0]?.value ?? 0,
|
|
78
|
+
memberships: membershipRows[0]?.value ?? 0,
|
|
79
|
+
openInvitations: invitationRows[0]?.value ?? 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WORKSPACES_INVITATION_SEND_REQUESTED,
|
|
3
|
+
workspacesEventSchemas,
|
|
4
|
+
} from '@shipfox/api-workspaces-dto';
|
|
5
|
+
import {workspacesModule} from './index.js';
|
|
6
|
+
|
|
7
|
+
vi.mock('#config.js', () => ({
|
|
8
|
+
config: {
|
|
9
|
+
CLIENT_BASE_URL: 'https://app.example.test',
|
|
10
|
+
},
|
|
11
|
+
mailer: {send: vi.fn()},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe('workspacesModule', () => {
|
|
15
|
+
test('registers workspace invitation outbox publisher and subscriber', () => {
|
|
16
|
+
const publisher = workspacesModule.publishers?.find((pub) => pub.name === 'workspaces');
|
|
17
|
+
const events = workspacesModule.subscribers?.map((subscriber) => subscriber.event);
|
|
18
|
+
|
|
19
|
+
expect(publisher?.eventSchemas).toBe(workspacesEventSchemas);
|
|
20
|
+
expect(Object.keys(publisher?.eventSchemas ?? {})).toEqual([
|
|
21
|
+
WORKSPACES_INVITATION_SEND_REQUESTED,
|
|
22
|
+
]);
|
|
23
|
+
expect(events).toContain(WORKSPACES_INVITATION_SEND_REQUESTED);
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WORKSPACES_INVITATION_SEND_REQUESTED,
|
|
3
|
+
type WorkspacesEventMap,
|
|
4
|
+
workspacesEventSchemas,
|
|
5
|
+
} from '@shipfox/api-workspaces-dto';
|
|
6
|
+
import type {ShipfoxModule} from '@shipfox/node-module';
|
|
7
|
+
import {subscriberFactory} from '@shipfox/node-module';
|
|
8
|
+
import {db, migrationsPath, workspacesOutbox} from '#db/index.js';
|
|
9
|
+
import {registerWorkspacesServiceMetrics} from '#metrics/index.js';
|
|
10
|
+
import {workspacesE2eRoutes} from '#presentation/e2eRoutes/index.js';
|
|
11
|
+
import {workspacesRoutes} from '#presentation/routes/index.js';
|
|
12
|
+
import {onInvitationSendRequested} from '#presentation/subscribers/index.js';
|
|
13
|
+
|
|
14
|
+
export type {Invitation} from '#core/entities/invitation.js';
|
|
15
|
+
export type {Membership} from '#core/entities/membership.js';
|
|
16
|
+
export type {Workspace, WorkspaceStatus} from '#core/entities/workspace.js';
|
|
17
|
+
export {
|
|
18
|
+
InvitationEmailMismatchError,
|
|
19
|
+
TokenAlreadyUsedError,
|
|
20
|
+
TokenExpiredError,
|
|
21
|
+
TokenInvalidError,
|
|
22
|
+
WorkspaceNotFoundError,
|
|
23
|
+
} from '#core/errors.js';
|
|
24
|
+
export {acceptWorkspaceInvitation, peekInvitationByRawToken} from '#core/invitations.js';
|
|
25
|
+
export {type EnsureMembershipParams, ensureMembership} from '#core/memberships.js';
|
|
26
|
+
export {getWorkspace, requireWorkspaceMembership} from '#core/workspaces.js';
|
|
27
|
+
export {db, migrationsPath} from '#db/index.js';
|
|
28
|
+
export {listMembershipsByUser} from '#db/memberships.js';
|
|
29
|
+
export {workspacesRoutes as routes} from '#presentation/routes/index.js';
|
|
30
|
+
|
|
31
|
+
const subscriber = subscriberFactory<WorkspacesEventMap>();
|
|
32
|
+
|
|
33
|
+
export const workspacesModule: ShipfoxModule = {
|
|
34
|
+
name: 'workspaces',
|
|
35
|
+
database: {db, migrationsPath},
|
|
36
|
+
routes: workspacesRoutes,
|
|
37
|
+
e2eRoutes: [workspacesE2eRoutes],
|
|
38
|
+
publishers: [
|
|
39
|
+
{name: 'workspaces', table: workspacesOutbox, db, eventSchemas: workspacesEventSchemas},
|
|
40
|
+
],
|
|
41
|
+
subscribers: [subscriber(WORKSPACES_INVITATION_SEND_REQUESTED, onInvitationSendRequested)],
|
|
42
|
+
metrics: registerWorkspacesServiceMetrics,
|
|
43
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {registerWorkspacesServiceMetrics} from './service.js';
|