@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.
Files changed (284) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +97 -0
  5. package/LICENSE +21 -0
  6. package/README.md +87 -0
  7. package/dist/config.d.ts +12 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +58 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/core/entities/index.d.ts +4 -0
  12. package/dist/core/entities/index.d.ts.map +1 -0
  13. package/dist/core/entities/index.js +3 -0
  14. package/dist/core/entities/index.js.map +1 -0
  15. package/dist/core/entities/invitation.d.ts +14 -0
  16. package/dist/core/entities/invitation.d.ts.map +1 -0
  17. package/dist/core/entities/invitation.js +3 -0
  18. package/dist/core/entities/invitation.js.map +1 -0
  19. package/dist/core/entities/membership.d.ts +10 -0
  20. package/dist/core/entities/membership.d.ts.map +1 -0
  21. package/dist/core/entities/membership.js +3 -0
  22. package/dist/core/entities/membership.js.map +1 -0
  23. package/dist/core/entities/workspace.d.ts +10 -0
  24. package/dist/core/entities/workspace.d.ts.map +1 -0
  25. package/dist/core/entities/workspace.js +3 -0
  26. package/dist/core/entities/workspace.js.map +1 -0
  27. package/dist/core/errors.d.ts +52 -0
  28. package/dist/core/errors.d.ts.map +1 -0
  29. package/dist/core/errors.js +104 -0
  30. package/dist/core/errors.js.map +1 -0
  31. package/dist/core/index.d.ts +8 -0
  32. package/dist/core/index.d.ts.map +1 -0
  33. package/dist/core/index.js +6 -0
  34. package/dist/core/index.js.map +1 -0
  35. package/dist/core/invitations.d.ts +51 -0
  36. package/dist/core/invitations.d.ts.map +1 -0
  37. package/dist/core/invitations.js +136 -0
  38. package/dist/core/invitations.js.map +1 -0
  39. package/dist/core/memberships.d.ts +9 -0
  40. package/dist/core/memberships.d.ts.map +1 -0
  41. package/dist/core/memberships.js +13 -0
  42. package/dist/core/memberships.js.map +1 -0
  43. package/dist/core/workspaces.d.ts +40 -0
  44. package/dist/core/workspaces.d.ts.map +1 -0
  45. package/dist/core/workspaces.js +85 -0
  46. package/dist/core/workspaces.js.map +1 -0
  47. package/dist/db/db.d.ts +1253 -0
  48. package/dist/db/db.d.ts.map +1 -0
  49. package/dist/db/db.js +24 -0
  50. package/dist/db/db.js.map +1 -0
  51. package/dist/db/index.d.ts +10 -0
  52. package/dist/db/index.d.ts.map +1 -0
  53. package/dist/db/index.js +10 -0
  54. package/dist/db/index.js.map +1 -0
  55. package/dist/db/invitations.d.ts +47 -0
  56. package/dist/db/invitations.d.ts.map +1 -0
  57. package/dist/db/invitations.js +107 -0
  58. package/dist/db/invitations.js.map +1 -0
  59. package/dist/db/memberships.d.ts +44 -0
  60. package/dist/db/memberships.d.ts.map +1 -0
  61. package/dist/db/memberships.js +75 -0
  62. package/dist/db/memberships.js.map +1 -0
  63. package/dist/db/schema/common.d.ts +2 -0
  64. package/dist/db/schema/common.d.ts.map +1 -0
  65. package/dist/db/schema/common.js +4 -0
  66. package/dist/db/schema/common.js.map +1 -0
  67. package/dist/db/schema/invitations.d.ts +199 -0
  68. package/dist/db/schema/invitations.d.ts.map +1 -0
  69. package/dist/db/schema/invitations.js +47 -0
  70. package/dist/db/schema/invitations.js.map +1 -0
  71. package/dist/db/schema/memberships.d.ts +131 -0
  72. package/dist/db/schema/memberships.d.ts.map +1 -0
  73. package/dist/db/schema/memberships.js +35 -0
  74. package/dist/db/schema/memberships.js.map +1 -0
  75. package/dist/db/schema/outbox.d.ts +195 -0
  76. package/dist/db/schema/outbox.d.ts.map +1 -0
  77. package/dist/db/schema/outbox.js +5 -0
  78. package/dist/db/schema/outbox.js.map +1 -0
  79. package/dist/db/schema/workspaces.d.ts +115 -0
  80. package/dist/db/schema/workspaces.d.ts.map +1 -0
  81. package/dist/db/schema/workspaces.js +32 -0
  82. package/dist/db/schema/workspaces.js.map +1 -0
  83. package/dist/db/workspaces.d.ts +20 -0
  84. package/dist/db/workspaces.d.ts.map +1 -0
  85. package/dist/db/workspaces.js +53 -0
  86. package/dist/db/workspaces.js.map +1 -0
  87. package/dist/index.d.ts +13 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +40 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/metrics/index.d.ts +2 -0
  92. package/dist/metrics/index.d.ts.map +1 -0
  93. package/dist/metrics/index.js +3 -0
  94. package/dist/metrics/index.js.map +1 -0
  95. package/dist/metrics/instance.d.ts +8 -0
  96. package/dist/metrics/instance.d.ts.map +1 -0
  97. package/dist/metrics/instance.js +41 -0
  98. package/dist/metrics/instance.js.map +1 -0
  99. package/dist/metrics/service.d.ts +2 -0
  100. package/dist/metrics/service.d.ts.map +1 -0
  101. package/dist/metrics/service.js +26 -0
  102. package/dist/metrics/service.js.map +1 -0
  103. package/dist/presentation/dto/index.d.ts +4 -0
  104. package/dist/presentation/dto/index.d.ts.map +1 -0
  105. package/dist/presentation/dto/index.js +5 -0
  106. package/dist/presentation/dto/index.js.map +1 -0
  107. package/dist/presentation/dto/invitation.d.ts +4 -0
  108. package/dist/presentation/dto/invitation.d.ts.map +1 -0
  109. package/dist/presentation/dto/invitation.js +15 -0
  110. package/dist/presentation/dto/invitation.js.map +1 -0
  111. package/dist/presentation/dto/membership.d.ts +5 -0
  112. package/dist/presentation/dto/membership.d.ts.map +1 -0
  113. package/dist/presentation/dto/membership.js +23 -0
  114. package/dist/presentation/dto/membership.js.map +1 -0
  115. package/dist/presentation/dto/workspace.d.ts +4 -0
  116. package/dist/presentation/dto/workspace.d.ts.map +1 -0
  117. package/dist/presentation/dto/workspace.js +12 -0
  118. package/dist/presentation/dto/workspace.js.map +1 -0
  119. package/dist/presentation/e2eRoutes/create-invitation.d.ts +2 -0
  120. package/dist/presentation/e2eRoutes/create-invitation.d.ts.map +1 -0
  121. package/dist/presentation/e2eRoutes/create-invitation.js +39 -0
  122. package/dist/presentation/e2eRoutes/create-invitation.js.map +1 -0
  123. package/dist/presentation/e2eRoutes/create-workspace.d.ts +2 -0
  124. package/dist/presentation/e2eRoutes/create-workspace.d.ts.map +1 -0
  125. package/dist/presentation/e2eRoutes/create-workspace.js +27 -0
  126. package/dist/presentation/e2eRoutes/create-workspace.js.map +1 -0
  127. package/dist/presentation/e2eRoutes/index.d.ts +3 -0
  128. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  129. package/dist/presentation/e2eRoutes/index.js +11 -0
  130. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  131. package/dist/presentation/index.d.ts +2 -0
  132. package/dist/presentation/index.d.ts.map +1 -0
  133. package/dist/presentation/index.js +3 -0
  134. package/dist/presentation/index.js.map +1 -0
  135. package/dist/presentation/routes/index.d.ts +3 -0
  136. package/dist/presentation/routes/index.d.ts.map +1 -0
  137. package/dist/presentation/routes/index.js +23 -0
  138. package/dist/presentation/routes/index.js.map +1 -0
  139. package/dist/presentation/routes/invitations/accept.d.ts +2 -0
  140. package/dist/presentation/routes/invitations/accept.d.ts.map +1 -0
  141. package/dist/presentation/routes/invitations/accept.js +67 -0
  142. package/dist/presentation/routes/invitations/accept.js.map +1 -0
  143. package/dist/presentation/routes/invitations/create.d.ts +2 -0
  144. package/dist/presentation/routes/invitations/create.d.ts.map +1 -0
  145. package/dist/presentation/routes/invitations/create.js +66 -0
  146. package/dist/presentation/routes/invitations/create.js.map +1 -0
  147. package/dist/presentation/routes/invitations/index.d.ts +4 -0
  148. package/dist/presentation/routes/invitations/index.d.ts.map +1 -0
  149. package/dist/presentation/routes/invitations/index.js +19 -0
  150. package/dist/presentation/routes/invitations/index.js.map +1 -0
  151. package/dist/presentation/routes/invitations/list.d.ts +2 -0
  152. package/dist/presentation/routes/invitations/list.d.ts.map +1 -0
  153. package/dist/presentation/routes/invitations/list.js +58 -0
  154. package/dist/presentation/routes/invitations/list.js.map +1 -0
  155. package/dist/presentation/routes/invitations/preview.d.ts +2 -0
  156. package/dist/presentation/routes/invitations/preview.d.ts.map +1 -0
  157. package/dist/presentation/routes/invitations/preview.js +47 -0
  158. package/dist/presentation/routes/invitations/preview.js.map +1 -0
  159. package/dist/presentation/routes/invitations/revoke.d.ts +2 -0
  160. package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -0
  161. package/dist/presentation/routes/invitations/revoke.js +66 -0
  162. package/dist/presentation/routes/invitations/revoke.js.map +1 -0
  163. package/dist/presentation/routes/members/index.d.ts +3 -0
  164. package/dist/presentation/routes/members/index.d.ts.map +1 -0
  165. package/dist/presentation/routes/members/index.js +8 -0
  166. package/dist/presentation/routes/members/index.js.map +1 -0
  167. package/dist/presentation/routes/members/list.d.ts +2 -0
  168. package/dist/presentation/routes/members/list.d.ts.map +1 -0
  169. package/dist/presentation/routes/members/list.js +58 -0
  170. package/dist/presentation/routes/members/list.js.map +1 -0
  171. package/dist/presentation/routes/members/remove.d.ts +2 -0
  172. package/dist/presentation/routes/members/remove.d.ts.map +1 -0
  173. package/dist/presentation/routes/members/remove.js +71 -0
  174. package/dist/presentation/routes/members/remove.js.map +1 -0
  175. package/dist/presentation/routes/workspaces/create.d.ts +2 -0
  176. package/dist/presentation/routes/workspaces/create.d.ts.map +1 -0
  177. package/dist/presentation/routes/workspaces/create.js +36 -0
  178. package/dist/presentation/routes/workspaces/create.js.map +1 -0
  179. package/dist/presentation/routes/workspaces/index.d.ts +3 -0
  180. package/dist/presentation/routes/workspaces/index.d.ts.map +1 -0
  181. package/dist/presentation/routes/workspaces/index.js +4 -0
  182. package/dist/presentation/routes/workspaces/index.js.map +1 -0
  183. package/dist/presentation/routes/workspaces/list.d.ts +2 -0
  184. package/dist/presentation/routes/workspaces/list.d.ts.map +1 -0
  185. package/dist/presentation/routes/workspaces/list.js +32 -0
  186. package/dist/presentation/routes/workspaces/list.js.map +1 -0
  187. package/dist/presentation/subscribers/index.d.ts +2 -0
  188. package/dist/presentation/subscribers/index.d.ts.map +1 -0
  189. package/dist/presentation/subscribers/index.js +3 -0
  190. package/dist/presentation/subscribers/index.js.map +1 -0
  191. package/dist/presentation/subscribers/on-invitation-send-requested.d.ts +3 -0
  192. package/dist/presentation/subscribers/on-invitation-send-requested.d.ts.map +1 -0
  193. package/dist/presentation/subscribers/on-invitation-send-requested.js +16 -0
  194. package/dist/presentation/subscribers/on-invitation-send-requested.js.map +1 -0
  195. package/dist/tsconfig.test.tsbuildinfo +1 -0
  196. package/drizzle/0000_initial.sql +9 -0
  197. package/drizzle/0001_memberships_invitations.sql +30 -0
  198. package/drizzle/0002_membership_user_snapshots.sql +40 -0
  199. package/drizzle/0003_outgoing_enchantress.sql +16 -0
  200. package/drizzle/meta/0000_snapshot.json +80 -0
  201. package/drizzle/meta/0001_snapshot.json +705 -0
  202. package/drizzle/meta/0003_snapshot.json +442 -0
  203. package/drizzle/meta/_journal.json +34 -0
  204. package/drizzle.config.ts +7 -0
  205. package/package.json +71 -0
  206. package/src/config.ts +51 -0
  207. package/src/core/entities/index.ts +3 -0
  208. package/src/core/entities/invitation.ts +13 -0
  209. package/src/core/entities/membership.ts +9 -0
  210. package/src/core/entities/workspace.ts +10 -0
  211. package/src/core/errors.ts +118 -0
  212. package/src/core/index.ts +35 -0
  213. package/src/core/invitations.test.ts +251 -0
  214. package/src/core/invitations.ts +191 -0
  215. package/src/core/memberships.test.ts +77 -0
  216. package/src/core/memberships.ts +17 -0
  217. package/src/core/workspaces.test.ts +72 -0
  218. package/src/core/workspaces.ts +126 -0
  219. package/src/db/db.ts +24 -0
  220. package/src/db/index.ts +43 -0
  221. package/src/db/invitations.test.ts +204 -0
  222. package/src/db/invitations.ts +235 -0
  223. package/src/db/memberships.test.ts +107 -0
  224. package/src/db/memberships.ts +139 -0
  225. package/src/db/schema/common.ts +5 -0
  226. package/src/db/schema/invitations.ts +47 -0
  227. package/src/db/schema/memberships.ts +39 -0
  228. package/src/db/schema/outbox.ts +4 -0
  229. package/src/db/schema/workspaces.ts +33 -0
  230. package/src/db/workspaces.test.ts +114 -0
  231. package/src/db/workspaces.ts +81 -0
  232. package/src/index.test.ts +25 -0
  233. package/src/index.ts +43 -0
  234. package/src/metrics/index.ts +1 -0
  235. package/src/metrics/instance.test.ts +74 -0
  236. package/src/metrics/instance.ts +57 -0
  237. package/src/metrics/service.ts +26 -0
  238. package/src/presentation/dto/index.ts +3 -0
  239. package/src/presentation/dto/invitation.ts +16 -0
  240. package/src/presentation/dto/membership.ts +27 -0
  241. package/src/presentation/dto/workspace.ts +13 -0
  242. package/src/presentation/e2eRoutes/create-invitation.ts +44 -0
  243. package/src/presentation/e2eRoutes/create-workspace.ts +30 -0
  244. package/src/presentation/e2eRoutes/index.ts +8 -0
  245. package/src/presentation/index.ts +1 -0
  246. package/src/presentation/routes/index.ts +23 -0
  247. package/src/presentation/routes/invitations/accept.test.ts +154 -0
  248. package/src/presentation/routes/invitations/accept.ts +70 -0
  249. package/src/presentation/routes/invitations/create.test.ts +99 -0
  250. package/src/presentation/routes/invitations/create.ts +60 -0
  251. package/src/presentation/routes/invitations/index.ts +17 -0
  252. package/src/presentation/routes/invitations/list.test.ts +94 -0
  253. package/src/presentation/routes/invitations/list.ts +53 -0
  254. package/src/presentation/routes/invitations/preview.test.ts +140 -0
  255. package/src/presentation/routes/invitations/preview.ts +46 -0
  256. package/src/presentation/routes/invitations/revoke.test.ts +99 -0
  257. package/src/presentation/routes/invitations/revoke.ts +64 -0
  258. package/src/presentation/routes/members/index.ts +5 -0
  259. package/src/presentation/routes/members/list.test.ts +95 -0
  260. package/src/presentation/routes/members/list.ts +53 -0
  261. package/src/presentation/routes/members/remove.test.ts +110 -0
  262. package/src/presentation/routes/members/remove.ts +66 -0
  263. package/src/presentation/routes/workspaces/create.test.ts +87 -0
  264. package/src/presentation/routes/workspaces/create.ts +36 -0
  265. package/src/presentation/routes/workspaces/index.ts +2 -0
  266. package/src/presentation/routes/workspaces/list.test.ts +56 -0
  267. package/src/presentation/routes/workspaces/list.ts +29 -0
  268. package/src/presentation/subscribers/index.ts +1 -0
  269. package/src/presentation/subscribers/invitation-send-requested.test.ts +64 -0
  270. package/src/presentation/subscribers/on-invitation-send-requested.ts +15 -0
  271. package/test/env.ts +8 -0
  272. package/test/factories/index.ts +3 -0
  273. package/test/factories/membership.ts +24 -0
  274. package/test/factories/user.ts +15 -0
  275. package/test/factories/workspace.ts +18 -0
  276. package/test/globalSetup.ts +15 -0
  277. package/test/index.ts +3 -0
  278. package/test/routes.ts +255 -0
  279. package/test/setup.ts +17 -0
  280. package/tsconfig.build.json +9 -0
  281. package/tsconfig.build.tsbuildinfo +1 -0
  282. package/tsconfig.json +3 -0
  283. package/tsconfig.test.json +8 -0
  284. package/vitest.config.ts +11 -0
@@ -0,0 +1,72 @@
1
+ import {findMembership} from '#db/memberships.js';
2
+ import {userFactory} from '#test/index.js';
3
+ import {MembershipRequiredError, WorkspaceNotFoundError} from './errors.js';
4
+ import {createWorkspaceForUser, requireWorkspaceMembership} from './workspaces.js';
5
+
6
+ describe('workspaces core', () => {
7
+ test('createWorkspaceForUser creates a workspace and membership for the user', async () => {
8
+ const user = userFactory.build();
9
+
10
+ const workspace = await createWorkspaceForUser({
11
+ name: 'Core Workspace',
12
+ userId: user.userId,
13
+ userEmail: user.email,
14
+ userName: user.name,
15
+ });
16
+ const membership = await findMembership({userId: user.userId, workspaceId: workspace.id});
17
+
18
+ expect(workspace.name).toBe('Core Workspace');
19
+ expect(membership).toBeDefined();
20
+ });
21
+
22
+ test('requireWorkspaceMembership returns the workspace + role when memberships include it', async () => {
23
+ const user = userFactory.build();
24
+ const workspace = await createWorkspaceForUser({
25
+ name: 'Member Workspace',
26
+ userId: user.userId,
27
+ userEmail: user.email,
28
+ userName: user.name,
29
+ });
30
+
31
+ const result = await requireWorkspaceMembership({
32
+ workspaceId: workspace.id,
33
+ userId: user.userId,
34
+ memberships: [{workspaceId: workspace.id, role: 'admin'}],
35
+ });
36
+
37
+ expect(result.workspace.id).toBe(workspace.id);
38
+ expect(result.userId).toBe(user.userId);
39
+ expect(result.role).toBe('admin');
40
+ });
41
+
42
+ test('requireWorkspaceMembership rejects when memberships do not include the workspace', async () => {
43
+ const owner = userFactory.build();
44
+ const outsider = userFactory.build();
45
+ const workspace = await createWorkspaceForUser({
46
+ name: 'Private Workspace',
47
+ userId: owner.userId,
48
+ userEmail: owner.email,
49
+ userName: owner.name,
50
+ });
51
+
52
+ const nonMember = requireWorkspaceMembership({
53
+ workspaceId: workspace.id,
54
+ userId: outsider.userId,
55
+ memberships: [],
56
+ });
57
+ await expect(nonMember).rejects.toBeInstanceOf(MembershipRequiredError);
58
+ });
59
+
60
+ test('requireWorkspaceMembership rejects with WorkspaceNotFoundError when workspace does not exist', async () => {
61
+ const owner = userFactory.build();
62
+ const ghostId = crypto.randomUUID();
63
+
64
+ const missingWorkspace = requireWorkspaceMembership({
65
+ workspaceId: ghostId,
66
+ userId: owner.userId,
67
+ memberships: [{workspaceId: ghostId, role: 'admin'}],
68
+ });
69
+
70
+ await expect(missingWorkspace).rejects.toBeInstanceOf(WorkspaceNotFoundError);
71
+ });
72
+ });
@@ -0,0 +1,126 @@
1
+ import type {UserContextMembership} from '@shipfox/api-auth-context';
2
+ import type {WorkspaceRole} from '@shipfox/api-workspaces-dto';
3
+ import {db} from '#db/db.js';
4
+ import {
5
+ findMembership,
6
+ listMembershipsByUser,
7
+ listMembershipsByWorkspace,
8
+ type MembershipWithUser,
9
+ type MembershipWithWorkspace,
10
+ removeMembership,
11
+ } from '#db/memberships.js';
12
+ import {memberships} from '#db/schema/memberships.js';
13
+ import {toWorkspace, workspaces} from '#db/schema/workspaces.js';
14
+ import {getWorkspaceById} from '#db/workspaces.js';
15
+ import type {Workspace} from './entities/workspace.js';
16
+ import {
17
+ MembershipNotFoundError,
18
+ MembershipRequiredError,
19
+ SelfRemovalNotAllowedError,
20
+ WorkspaceInactiveError,
21
+ WorkspaceNotFoundError,
22
+ } from './errors.js';
23
+
24
+ export interface RequireWorkspaceMembershipParams {
25
+ workspaceId: string;
26
+ userId: string;
27
+ memberships: ReadonlyArray<UserContextMembership>;
28
+ }
29
+
30
+ export interface RequireWorkspaceMembershipResult {
31
+ workspace: Workspace;
32
+ workspaceId: string;
33
+ userId: string;
34
+ role: WorkspaceRole;
35
+ }
36
+
37
+ export async function requireWorkspaceMembership(
38
+ params: RequireWorkspaceMembershipParams,
39
+ ): Promise<RequireWorkspaceMembershipResult> {
40
+ const membership = params.memberships.find((m) => m.workspaceId === params.workspaceId);
41
+ if (!membership) {
42
+ throw new MembershipRequiredError(params.workspaceId);
43
+ }
44
+
45
+ const workspace = await getWorkspaceById(params.workspaceId);
46
+ if (!workspace) {
47
+ throw new WorkspaceNotFoundError(params.workspaceId);
48
+ }
49
+ if (workspace.status !== 'active') {
50
+ throw new WorkspaceInactiveError(params.workspaceId);
51
+ }
52
+
53
+ return {workspace, workspaceId: workspace.id, userId: params.userId, role: membership.role};
54
+ }
55
+
56
+ export async function createWorkspaceForUser(params: {
57
+ name: string;
58
+ userId: string;
59
+ userEmail?: string | undefined;
60
+ userName?: string | null | undefined;
61
+ }): Promise<Workspace> {
62
+ return await db().transaction(async (tx) => {
63
+ const [workspaceRow] = await tx.insert(workspaces).values({name: params.name}).returning();
64
+ if (!workspaceRow) throw new Error('Insert returned no rows');
65
+ await tx.insert(memberships).values({
66
+ userId: params.userId,
67
+ userEmail: params.userEmail ?? `user-${params.userId}@example.local`,
68
+ userName: params.userName ?? null,
69
+ workspaceId: workspaceRow.id,
70
+ });
71
+ return toWorkspace(workspaceRow);
72
+ });
73
+ }
74
+
75
+ export async function getWorkspace(params: {workspaceId: string}): Promise<Workspace> {
76
+ const workspace = await getWorkspaceById(params.workspaceId);
77
+ if (!workspace) {
78
+ throw new WorkspaceNotFoundError(params.workspaceId);
79
+ }
80
+
81
+ return workspace;
82
+ }
83
+
84
+ export async function listUserWorkspaceMemberships(params: {
85
+ userId: string;
86
+ }): Promise<MembershipWithWorkspace[]> {
87
+ return await listMembershipsByUser({userId: params.userId});
88
+ }
89
+
90
+ export async function listWorkspaceMembers(params: {
91
+ workspaceId: string;
92
+ requesterUserId: string;
93
+ requesterMemberships: ReadonlyArray<UserContextMembership>;
94
+ }): Promise<MembershipWithUser[]> {
95
+ await requireWorkspaceMembership({
96
+ workspaceId: params.workspaceId,
97
+ userId: params.requesterUserId,
98
+ memberships: params.requesterMemberships,
99
+ });
100
+
101
+ return listMembershipsByWorkspace({workspaceId: params.workspaceId});
102
+ }
103
+
104
+ export async function removeWorkspaceMember(params: {
105
+ workspaceId: string;
106
+ requesterUserId: string;
107
+ requesterMemberships: ReadonlyArray<UserContextMembership>;
108
+ userId: string;
109
+ }): Promise<void> {
110
+ await requireWorkspaceMembership({
111
+ workspaceId: params.workspaceId,
112
+ userId: params.requesterUserId,
113
+ memberships: params.requesterMemberships,
114
+ });
115
+
116
+ if (params.userId === params.requesterUserId) {
117
+ throw new SelfRemovalNotAllowedError();
118
+ }
119
+
120
+ const target = await findMembership({userId: params.userId, workspaceId: params.workspaceId});
121
+ if (!target) {
122
+ throw new MembershipNotFoundError(params.userId, params.workspaceId);
123
+ }
124
+
125
+ await removeMembership({userId: params.userId, workspaceId: params.workspaceId});
126
+ }
package/src/db/db.ts ADDED
@@ -0,0 +1,24 @@
1
+ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
+ import {pgClient} from '@shipfox/node-postgres';
3
+ import {invitations} from './schema/invitations.js';
4
+ import {memberships} from './schema/memberships.js';
5
+ import {workspacesOutbox} from './schema/outbox.js';
6
+ import {workspaces} from './schema/workspaces.js';
7
+
8
+ export const schema = {
9
+ workspaces,
10
+ memberships,
11
+ invitations,
12
+ workspacesOutbox,
13
+ };
14
+
15
+ let _db: NodePgDatabase<typeof schema> | undefined;
16
+
17
+ export function db() {
18
+ if (!_db) _db = drizzle(pgClient(), {schema});
19
+ return _db;
20
+ }
21
+
22
+ export function closeDb(): void {
23
+ _db = undefined;
24
+ }
@@ -0,0 +1,43 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+
4
+ export {closeDb, db, schema} from './db.js';
5
+ export type {
6
+ AcceptInvitationParams,
7
+ AcceptInvitationResult,
8
+ CreateInvitationParams,
9
+ } from './invitations.js';
10
+ export {
11
+ acceptInvitation,
12
+ createInvitation,
13
+ findInvitationByToken,
14
+ listOpenInvitationsByWorkspace,
15
+ revokeInvitation,
16
+ } from './invitations.js';
17
+ export type {
18
+ CreateMembershipParams,
19
+ MembershipWithUser,
20
+ MembershipWithWorkspace,
21
+ RemoveMembershipParams,
22
+ } from './memberships.js';
23
+ export {
24
+ createMembership,
25
+ findMembership,
26
+ listMembershipsByUser,
27
+ listMembershipsByWorkspace,
28
+ removeMembership,
29
+ } from './memberships.js';
30
+ export {workspacesOutbox} from './schema/outbox.js';
31
+ export type {
32
+ CreateWorkspaceParams,
33
+ UpdateWorkspaceParams,
34
+ WorkspaceServiceMetrics,
35
+ } from './workspaces.js';
36
+ export {
37
+ createWorkspace,
38
+ getWorkspaceById,
39
+ getWorkspaceServiceMetrics,
40
+ updateWorkspace,
41
+ } from './workspaces.js';
42
+
43
+ export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
@@ -0,0 +1,204 @@
1
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
2
+ import {eq, sql} from 'drizzle-orm';
3
+ import {OpenInvitationExistsError} from '#core/errors.js';
4
+ import {db} from './db.js';
5
+ import {
6
+ acceptInvitation,
7
+ createInvitation,
8
+ findInvitationByToken,
9
+ listOpenInvitationsByWorkspace,
10
+ revokeInvitation,
11
+ } from './invitations.js';
12
+ import {invitations} from './schema/invitations.js';
13
+ import {createWorkspace} from './workspaces.js';
14
+
15
+ function emailFor(suffix: string): string {
16
+ return `${suffix}-${crypto.randomUUID()}@example.com`;
17
+ }
18
+
19
+ async function createUser(params: {email: string; hashedPassword?: string; name?: string}) {
20
+ await Promise.resolve();
21
+ return {userId: crypto.randomUUID(), email: params.email, name: null};
22
+ }
23
+
24
+ describe('invitations db', () => {
25
+ test('creates an invitation', async () => {
26
+ const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
27
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
28
+
29
+ const invitation = await createInvitation({
30
+ workspaceId: workspace.id,
31
+ email: emailFor('guest'),
32
+ hashedToken: hashOpaqueToken('inv-1'),
33
+ expiresAt: new Date(Date.now() + 86_400_000),
34
+ invitedByUserId: inviter.userId,
35
+ skipEmail: true,
36
+ });
37
+
38
+ expect(invitation.workspaceId).toBe(workspace.id);
39
+ expect(invitation.acceptedAt).toBeNull();
40
+ });
41
+
42
+ test('rejects when an unexpired open invite already exists', async () => {
43
+ const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
44
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
45
+ const guest = emailFor('guest');
46
+ await createInvitation({
47
+ workspaceId: workspace.id,
48
+ email: guest,
49
+ hashedToken: hashOpaqueToken('first'),
50
+ expiresAt: new Date(Date.now() + 86_400_000),
51
+ invitedByUserId: inviter.userId,
52
+ skipEmail: true,
53
+ });
54
+
55
+ await expect(
56
+ createInvitation({
57
+ workspaceId: workspace.id,
58
+ email: guest,
59
+ hashedToken: hashOpaqueToken('second'),
60
+ expiresAt: new Date(Date.now() + 86_400_000),
61
+ invitedByUserId: inviter.userId,
62
+ skipEmail: true,
63
+ }),
64
+ ).rejects.toBeInstanceOf(OpenInvitationExistsError);
65
+ });
66
+
67
+ test('on create, expired invites for same (workspace, email) are deleted first', async () => {
68
+ const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
69
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
70
+ const guest = emailFor('guest');
71
+ const expiredToken = hashOpaqueToken('expired-inv');
72
+ await db().insert(invitations).values({
73
+ workspaceId: workspace.id,
74
+ email: guest,
75
+ hashedToken: expiredToken,
76
+ expiresAt: sql`now() - interval '1 day'`,
77
+ invitedByUserId: inviter.userId,
78
+ });
79
+
80
+ const fresh = await createInvitation({
81
+ workspaceId: workspace.id,
82
+ email: guest,
83
+ hashedToken: hashOpaqueToken('fresh-inv'),
84
+ expiresAt: new Date(Date.now() + 86_400_000),
85
+ invitedByUserId: inviter.userId,
86
+ skipEmail: true,
87
+ });
88
+
89
+ expect(fresh.id).toBeDefined();
90
+ const stillThere = await db()
91
+ .select()
92
+ .from(invitations)
93
+ .where(eq(invitations.hashedToken, expiredToken));
94
+ expect(stillThere).toHaveLength(0);
95
+ });
96
+
97
+ test('acceptInvitation creates membership + marks accepted (transactional)', async () => {
98
+ const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
99
+ const accepter = await createUser({email: emailFor('accepter'), hashedPassword: 'h'});
100
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
101
+ const inv = await createInvitation({
102
+ workspaceId: workspace.id,
103
+ email: accepter.email,
104
+ hashedToken: hashOpaqueToken('inv-accept'),
105
+ expiresAt: new Date(Date.now() + 86_400_000),
106
+ invitedByUserId: inviter.userId,
107
+ skipEmail: true,
108
+ });
109
+
110
+ const result = await acceptInvitation({
111
+ invitationId: inv.id,
112
+ acceptedByUserId: accepter.userId,
113
+ });
114
+
115
+ expect(result?.invitation.acceptedAt).toBeInstanceOf(Date);
116
+ expect(result?.invitation.acceptedByUserId).toBe(accepter.userId);
117
+ expect(result?.membership.userId).toBe(accepter.userId);
118
+ expect(result?.alreadyMember).toBe(false);
119
+ });
120
+
121
+ test('acceptInvitation is idempotent when caller is already a member', async () => {
122
+ const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
123
+ const member = await createUser({email: emailFor('member'), hashedPassword: 'h'});
124
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
125
+ const {createMembership} = await import('./memberships.js');
126
+ const existing = await createMembership({userId: member.userId, workspaceId: workspace.id});
127
+ const inv = await createInvitation({
128
+ workspaceId: workspace.id,
129
+ email: member.email,
130
+ hashedToken: hashOpaqueToken('inv-idem'),
131
+ expiresAt: new Date(Date.now() + 86_400_000),
132
+ invitedByUserId: inviter.userId,
133
+ skipEmail: true,
134
+ });
135
+
136
+ const result = await acceptInvitation({invitationId: inv.id, acceptedByUserId: member.userId});
137
+
138
+ expect(result?.alreadyMember).toBe(true);
139
+ expect(result?.membership.id).toBe(existing.id);
140
+ });
141
+
142
+ test('acceptInvitation returns undefined when expired', async () => {
143
+ const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
144
+ const accepter = await createUser({email: emailFor('accepter'), hashedPassword: 'h'});
145
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
146
+ const expiredToken = hashOpaqueToken('expired-accept');
147
+ const [inserted] = await db()
148
+ .insert(invitations)
149
+ .values({
150
+ workspaceId: workspace.id,
151
+ email: accepter.email,
152
+ hashedToken: expiredToken,
153
+ expiresAt: sql`now() - interval '1 second'`,
154
+ invitedByUserId: inviter.userId,
155
+ })
156
+ .returning();
157
+ if (!inserted) throw new Error('insert failed');
158
+
159
+ const result = await acceptInvitation({
160
+ invitationId: inserted.id,
161
+ acceptedByUserId: accepter.userId,
162
+ });
163
+
164
+ expect(result).toBeUndefined();
165
+ });
166
+
167
+ test('lists only open invitations and revokes them', async () => {
168
+ const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
169
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
170
+ const inv = await createInvitation({
171
+ workspaceId: workspace.id,
172
+ email: emailFor('guest'),
173
+ hashedToken: hashOpaqueToken('inv-list'),
174
+ expiresAt: new Date(Date.now() + 86_400_000),
175
+ invitedByUserId: inviter.userId,
176
+ skipEmail: true,
177
+ });
178
+
179
+ const before = await listOpenInvitationsByWorkspace({workspaceId: workspace.id});
180
+ await revokeInvitation({invitationId: inv.id});
181
+ const after = await listOpenInvitationsByWorkspace({workspaceId: workspace.id});
182
+
183
+ expect(before.some((i) => i.id === inv.id)).toBe(true);
184
+ expect(after.some((i) => i.id === inv.id)).toBe(false);
185
+ });
186
+
187
+ test('findInvitationByToken returns matching row', async () => {
188
+ const inviter = await createUser({email: emailFor('inviter'), hashedPassword: 'h'});
189
+ const workspace = await createWorkspace({name: `Workspace ${crypto.randomUUID()}`});
190
+ const tokenHash = hashOpaqueToken('inv-find');
191
+ const inv = await createInvitation({
192
+ workspaceId: workspace.id,
193
+ email: emailFor('guest'),
194
+ hashedToken: tokenHash,
195
+ expiresAt: new Date(Date.now() + 86_400_000),
196
+ invitedByUserId: inviter.userId,
197
+ skipEmail: true,
198
+ });
199
+
200
+ const found = await findInvitationByToken({hashedToken: tokenHash});
201
+
202
+ expect(found?.id).toBe(inv.id);
203
+ });
204
+ });
@@ -0,0 +1,235 @@
1
+ import {
2
+ WORKSPACES_INVITATION_SEND_REQUESTED,
3
+ type WorkspacesEventMap,
4
+ } from '@shipfox/api-workspaces-dto';
5
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
6
+ import {and, eq, gt, isNull, lt, sql} from 'drizzle-orm';
7
+ import type {Invitation} from '#core/entities/invitation.js';
8
+ import type {Membership} from '#core/entities/membership.js';
9
+ import {OpenInvitationExistsError} from '#core/errors.js';
10
+ import {
11
+ recordWorkspaceInvitationAccepted,
12
+ recordWorkspaceInvitationCreated,
13
+ recordWorkspaceMembershipChanged,
14
+ } from '#metrics/instance.js';
15
+ import {db} from './db.js';
16
+ import {membershipValues} from './memberships.js';
17
+ import {invitations, toInvitation} from './schema/invitations.js';
18
+ import {memberships, toMembership} from './schema/memberships.js';
19
+ import {workspacesOutbox} from './schema/outbox.js';
20
+
21
+ interface CreateInvitationBaseParams {
22
+ workspaceId: string;
23
+ email: string;
24
+ hashedToken: string;
25
+ expiresAt: Date;
26
+ invitedByUserId: string;
27
+ invitedByDisplay?: string | null;
28
+ }
29
+
30
+ export type CreateInvitationParams = CreateInvitationBaseParams &
31
+ (
32
+ | {
33
+ sendEmail: {
34
+ workspaceName: string;
35
+ inviterName: string;
36
+ inviteLink: string;
37
+ };
38
+ skipEmail?: never;
39
+ }
40
+ | {sendEmail?: never; skipEmail: true}
41
+ );
42
+
43
+ export async function createInvitation(params: CreateInvitationParams): Promise<Invitation> {
44
+ const result = await db().transaction(async (tx) => {
45
+ await tx
46
+ .delete(invitations)
47
+ .where(
48
+ and(
49
+ eq(invitations.workspaceId, params.workspaceId),
50
+ eq(invitations.email, params.email),
51
+ isNull(invitations.acceptedAt),
52
+ lt(invitations.expiresAt, sql`now()`),
53
+ ),
54
+ );
55
+
56
+ const open = await tx
57
+ .select()
58
+ .from(invitations)
59
+ .where(
60
+ and(
61
+ eq(invitations.workspaceId, params.workspaceId),
62
+ eq(invitations.email, params.email),
63
+ isNull(invitations.acceptedAt),
64
+ ),
65
+ )
66
+ .limit(1);
67
+
68
+ if (open.length > 0) {
69
+ throw new OpenInvitationExistsError(params.email);
70
+ }
71
+
72
+ const rows = await tx
73
+ .insert(invitations)
74
+ .values({
75
+ workspaceId: params.workspaceId,
76
+ email: params.email,
77
+ hashedToken: params.hashedToken,
78
+ expiresAt: params.expiresAt,
79
+ invitedByUserId: params.invitedByUserId,
80
+ invitedByDisplay: params.invitedByDisplay ?? null,
81
+ })
82
+ .returning();
83
+
84
+ const row = rows[0];
85
+ if (!row) throw new Error('Insert returned no rows');
86
+ if (params.sendEmail) {
87
+ await writeOutboxEvent<WorkspacesEventMap>(tx, workspacesOutbox, {
88
+ type: WORKSPACES_INVITATION_SEND_REQUESTED,
89
+ payload: {
90
+ email: params.email,
91
+ ...params.sendEmail,
92
+ },
93
+ });
94
+ }
95
+ return {
96
+ invitation: toInvitation(row),
97
+ emailRequested: params.sendEmail ? ('requested' as const) : ('skipped' as const),
98
+ };
99
+ });
100
+
101
+ recordWorkspaceInvitationCreated(result.emailRequested);
102
+ return result.invitation;
103
+ }
104
+
105
+ export async function findInvitationByToken(params: {
106
+ hashedToken: string;
107
+ }): Promise<Invitation | undefined> {
108
+ const rows = await db()
109
+ .select()
110
+ .from(invitations)
111
+ .where(eq(invitations.hashedToken, params.hashedToken))
112
+ .limit(1);
113
+
114
+ const row = rows[0];
115
+ if (!row) return undefined;
116
+ return toInvitation(row);
117
+ }
118
+
119
+ export async function findInvitationById(params: {id: string}): Promise<Invitation | undefined> {
120
+ const rows = await db().select().from(invitations).where(eq(invitations.id, params.id)).limit(1);
121
+
122
+ const row = rows[0];
123
+ if (!row) return undefined;
124
+ return toInvitation(row);
125
+ }
126
+
127
+ export async function listOpenInvitationsByWorkspace(params: {
128
+ workspaceId: string;
129
+ }): Promise<Invitation[]> {
130
+ const rows = await db()
131
+ .select()
132
+ .from(invitations)
133
+ .where(
134
+ and(
135
+ eq(invitations.workspaceId, params.workspaceId),
136
+ isNull(invitations.acceptedAt),
137
+ gt(invitations.expiresAt, sql`now()`),
138
+ ),
139
+ );
140
+
141
+ return rows.map(toInvitation);
142
+ }
143
+
144
+ export async function revokeInvitation(params: {invitationId: string}): Promise<void> {
145
+ await db().delete(invitations).where(eq(invitations.id, params.invitationId));
146
+ }
147
+
148
+ export interface AcceptInvitationParams {
149
+ invitationId: string;
150
+ acceptedByUserId: string;
151
+ acceptedByUserName?: string | null | undefined;
152
+ }
153
+
154
+ export interface AcceptInvitationResult {
155
+ invitation: Invitation;
156
+ membership: Membership;
157
+ alreadyMember: boolean;
158
+ }
159
+
160
+ export async function acceptInvitation(
161
+ params: AcceptInvitationParams,
162
+ ): Promise<AcceptInvitationResult | undefined> {
163
+ const result = await db().transaction(async (tx) => {
164
+ const inv = await tx
165
+ .select()
166
+ .from(invitations)
167
+ .where(
168
+ and(
169
+ eq(invitations.id, params.invitationId),
170
+ isNull(invitations.acceptedAt),
171
+ gt(invitations.expiresAt, sql`now()`),
172
+ ),
173
+ )
174
+ .limit(1);
175
+
176
+ const invRow = inv[0];
177
+ if (!invRow) return undefined;
178
+
179
+ const existing = await tx
180
+ .select()
181
+ .from(memberships)
182
+ .where(
183
+ and(
184
+ eq(memberships.userId, params.acceptedByUserId),
185
+ eq(memberships.workspaceId, invRow.workspaceId),
186
+ ),
187
+ )
188
+ .limit(1);
189
+
190
+ let membership: Membership;
191
+ let alreadyMember = false;
192
+ const existingRow = existing[0];
193
+
194
+ if (existingRow) {
195
+ alreadyMember = true;
196
+ membership = toMembership(existingRow);
197
+ } else {
198
+ const created = await tx
199
+ .insert(memberships)
200
+ .values(
201
+ membershipValues({
202
+ userId: params.acceptedByUserId,
203
+ userEmail: invRow.email,
204
+ userName: params.acceptedByUserName ?? null,
205
+ workspaceId: invRow.workspaceId,
206
+ }),
207
+ )
208
+ .returning();
209
+ const createdRow = created[0];
210
+ if (!createdRow) throw new Error('Insert returned no rows');
211
+ membership = toMembership(createdRow);
212
+ }
213
+
214
+ const updated = await tx
215
+ .update(invitations)
216
+ .set({
217
+ acceptedAt: sql`now()`,
218
+ acceptedByUserId: params.acceptedByUserId,
219
+ updatedAt: sql`now()`,
220
+ })
221
+ .where(eq(invitations.id, params.invitationId))
222
+ .returning();
223
+
224
+ const updatedRow = updated[0];
225
+ if (!updatedRow) throw new Error('Update returned no rows');
226
+
227
+ return {invitation: toInvitation(updatedRow), membership, alreadyMember};
228
+ });
229
+
230
+ if (result && !result.alreadyMember) recordWorkspaceMembershipChanged('added');
231
+ if (result) {
232
+ recordWorkspaceInvitationAccepted(result.alreadyMember ? 'already_member' : 'added');
233
+ }
234
+ return result;
235
+ }