@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,118 @@
1
+ export class WorkspaceNotFoundError extends Error {
2
+ constructor(workspaceId: string) {
3
+ super(`Workspace not found: ${workspaceId}`);
4
+ this.name = 'WorkspaceNotFoundError';
5
+ }
6
+ }
7
+
8
+ export class WorkspaceInactiveError extends Error {
9
+ constructor(workspaceId: string) {
10
+ super(`Workspace is not active: ${workspaceId}`);
11
+ this.name = 'WorkspaceInactiveError';
12
+ }
13
+ }
14
+
15
+ export class InvitationNotFoundError extends Error {
16
+ constructor(id: string) {
17
+ super(`Invitation not found: ${id}`);
18
+ this.name = 'InvitationNotFoundError';
19
+ }
20
+ }
21
+
22
+ export class InvitationWorkspaceMismatchError extends Error {
23
+ constructor() {
24
+ super('Invitation does not belong to this workspace');
25
+ this.name = 'InvitationWorkspaceMismatchError';
26
+ }
27
+ }
28
+
29
+ export class MembershipNotFoundError extends Error {
30
+ constructor(userId: string, workspaceId: string) {
31
+ super(`Membership not found for user ${userId} in workspace ${workspaceId}`);
32
+ this.name = 'MembershipNotFoundError';
33
+ }
34
+ }
35
+
36
+ export class UserNotFoundError extends Error {
37
+ constructor(idOrEmail: string) {
38
+ super(`User not found: ${idOrEmail}`);
39
+ this.name = 'UserNotFoundError';
40
+ }
41
+ }
42
+
43
+ export class EmailTakenError extends Error {
44
+ constructor(email: string) {
45
+ super(`Email already registered: ${email}`);
46
+ this.name = 'EmailTakenError';
47
+ }
48
+ }
49
+
50
+ export class InvalidCredentialsError extends Error {
51
+ constructor() {
52
+ super('Invalid credentials');
53
+ this.name = 'InvalidCredentialsError';
54
+ }
55
+ }
56
+
57
+ export class EmailNotVerifiedError extends Error {
58
+ constructor() {
59
+ super('Email not verified');
60
+ this.name = 'EmailNotVerifiedError';
61
+ }
62
+ }
63
+
64
+ export class TokenInvalidError extends Error {
65
+ constructor(reason?: string) {
66
+ super(reason ? `Invalid token: ${reason}` : 'Invalid token');
67
+ this.name = 'TokenInvalidError';
68
+ }
69
+ }
70
+
71
+ export class TokenExpiredError extends Error {
72
+ constructor() {
73
+ super('Token has expired');
74
+ this.name = 'TokenExpiredError';
75
+ }
76
+ }
77
+
78
+ export class TokenAlreadyUsedError extends Error {
79
+ constructor() {
80
+ super('Token has already been used');
81
+ this.name = 'TokenAlreadyUsedError';
82
+ }
83
+ }
84
+
85
+ export class MembershipRequiredError extends Error {
86
+ constructor(workspaceId: string) {
87
+ super(`Membership required for workspace: ${workspaceId}`);
88
+ this.name = 'MembershipRequiredError';
89
+ }
90
+ }
91
+
92
+ export class LastMemberError extends Error {
93
+ constructor(workspaceId: string) {
94
+ super(`Cannot remove the last member of workspace: ${workspaceId}`);
95
+ this.name = 'LastMemberError';
96
+ }
97
+ }
98
+
99
+ export class SelfRemovalNotAllowedError extends Error {
100
+ constructor() {
101
+ super('Cannot remove yourself from a workspace');
102
+ this.name = 'SelfRemovalNotAllowedError';
103
+ }
104
+ }
105
+
106
+ export class InvitationEmailMismatchError extends Error {
107
+ constructor() {
108
+ super('Invitation email does not match authenticated user');
109
+ this.name = 'InvitationEmailMismatchError';
110
+ }
111
+ }
112
+
113
+ export class OpenInvitationExistsError extends Error {
114
+ constructor(email: string) {
115
+ super(`An open invitation already exists for: ${email}`);
116
+ this.name = 'OpenInvitationExistsError';
117
+ }
118
+ }
@@ -0,0 +1,35 @@
1
+ export type {Invitation} from './entities/invitation.js';
2
+ export type {Membership} from './entities/membership.js';
3
+ export type {Workspace, WorkspaceStatus} from './entities/workspace.js';
4
+ export {
5
+ InvitationEmailMismatchError,
6
+ InvitationNotFoundError,
7
+ InvitationWorkspaceMismatchError,
8
+ LastMemberError,
9
+ MembershipNotFoundError,
10
+ MembershipRequiredError,
11
+ OpenInvitationExistsError,
12
+ SelfRemovalNotAllowedError,
13
+ TokenAlreadyUsedError,
14
+ TokenExpiredError,
15
+ WorkspaceNotFoundError,
16
+ } from './errors.js';
17
+ export {
18
+ acceptWorkspaceInvitation,
19
+ createWorkspaceInvitation,
20
+ listWorkspaceInvitations,
21
+ type PreviewInvitationResult,
22
+ previewInvitation,
23
+ revokeWorkspaceInvitation,
24
+ } from './invitations.js';
25
+ export {type EnsureMembershipParams, ensureMembership} from './memberships.js';
26
+ export {
27
+ createWorkspaceForUser,
28
+ getWorkspace,
29
+ listUserWorkspaceMemberships,
30
+ listWorkspaceMembers,
31
+ type RequireWorkspaceMembershipParams,
32
+ type RequireWorkspaceMembershipResult,
33
+ removeWorkspaceMember,
34
+ requireWorkspaceMembership,
35
+ } from './workspaces.js';
@@ -0,0 +1,251 @@
1
+ import {WORKSPACES_INVITATION_SEND_REQUESTED} from '@shipfox/api-workspaces-dto';
2
+ import type {Mailer, MailMessage} from '@shipfox/node-mailer';
3
+ import {and, desc, eq, sql} from 'drizzle-orm';
4
+ import {db} from '#db/db.js';
5
+ import {createMembership} from '#db/memberships.js';
6
+ import {workspacesOutbox} from '#db/schema/outbox.js';
7
+ import {userFactory, workspaceFactory} from '#test/index.js';
8
+ import {
9
+ InvitationEmailMismatchError,
10
+ OpenInvitationExistsError,
11
+ TokenAlreadyUsedError,
12
+ } from './errors.js';
13
+ import {acceptWorkspaceInvitation, createWorkspaceInvitation} from './invitations.js';
14
+
15
+ const testConfig = vi.hoisted(
16
+ (): {
17
+ captured: MailMessage[];
18
+ mailer: Mailer;
19
+ clientBaseUrl: string;
20
+ } => {
21
+ const captured: MailMessage[] = [];
22
+ const mailer: Mailer = {
23
+ send: (message) => {
24
+ captured.push(message);
25
+ return Promise.resolve();
26
+ },
27
+ };
28
+ return {
29
+ captured,
30
+ mailer,
31
+ clientBaseUrl: 'https://app.example.test',
32
+ };
33
+ },
34
+ );
35
+
36
+ vi.mock('#config.js', () => ({
37
+ config: {
38
+ WORKSPACE_JWT_SECRET: 'invitation-core-test-secret',
39
+ WORKSPACE_JWT_EXPIRES_IN: '7d',
40
+ CLIENT_BASE_URL: testConfig.clientBaseUrl,
41
+ },
42
+ mailer: testConfig.mailer,
43
+ }));
44
+
45
+ const TOKEN_RE = /token=([\w\-_=]+)/;
46
+
47
+ function extractToken(link: string | undefined): string {
48
+ const match = link?.match(TOKEN_RE);
49
+ if (!match?.[1]) {
50
+ throw new Error('Expected link to contain a token');
51
+ }
52
+ return match[1];
53
+ }
54
+
55
+ async function invitationEventsTo(email: string) {
56
+ return await db()
57
+ .select()
58
+ .from(workspacesOutbox)
59
+ .where(
60
+ and(
61
+ eq(workspacesOutbox.eventType, WORKSPACES_INVITATION_SEND_REQUESTED),
62
+ sql`${workspacesOutbox.payload}->>'email' = ${email}`,
63
+ ),
64
+ )
65
+ .orderBy(desc(workspacesOutbox.createdAt));
66
+ }
67
+
68
+ async function latestInvitationPayload(email: string) {
69
+ const row = (await invitationEventsTo(email))[0];
70
+ if (!row) throw new Error(`No invitation outbox event for ${email}`);
71
+ return row.payload as {
72
+ email: string;
73
+ workspaceName: string;
74
+ inviterName: string;
75
+ inviteLink: string;
76
+ };
77
+ }
78
+
79
+ describe('invitations core', () => {
80
+ let captured: MailMessage[];
81
+
82
+ beforeEach(() => {
83
+ captured = testConfig.captured;
84
+ captured.length = 0;
85
+ });
86
+
87
+ test('createWorkspaceInvitation creates an invitation and queues an email', async () => {
88
+ const inviter = userFactory.build();
89
+ const workspace = await workspaceFactory.create();
90
+ await createMembership({
91
+ userId: inviter.userId,
92
+ userEmail: inviter.email,
93
+ userName: inviter.name,
94
+ workspaceId: workspace.id,
95
+ });
96
+ const email = `invitee-${crypto.randomUUID()}@example.com`;
97
+
98
+ const invitation = await createWorkspaceInvitation({
99
+ workspaceId: workspace.id,
100
+ email,
101
+ invitedByUserId: inviter.userId,
102
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
103
+ });
104
+
105
+ expect(invitation.email).toBe(email);
106
+ expect(captured).toHaveLength(0);
107
+ expect(await invitationEventsTo(email)).toHaveLength(1);
108
+ });
109
+
110
+ test('createWorkspaceInvitation queues the workspace name and inviter', async () => {
111
+ const inviter = userFactory.build();
112
+ const workspace = await workspaceFactory.create();
113
+ await createMembership({
114
+ userId: inviter.userId,
115
+ userEmail: inviter.email,
116
+ userName: inviter.name,
117
+ workspaceId: workspace.id,
118
+ });
119
+ const email = `invitee-${crypto.randomUUID()}@example.com`;
120
+
121
+ await createWorkspaceInvitation({
122
+ workspaceId: workspace.id,
123
+ email,
124
+ invitedByUserId: inviter.userId,
125
+ invitedByDisplay: 'Dana Scully',
126
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
127
+ });
128
+
129
+ const payload = await latestInvitationPayload(email);
130
+ expect(captured).toHaveLength(0);
131
+ expect(payload).toMatchObject({
132
+ email,
133
+ workspaceName: workspace.name,
134
+ inviterName: 'Dana Scully',
135
+ });
136
+ expect(payload.inviteLink).toContain(`${testConfig.clientBaseUrl}/invitations/accept?token=`);
137
+ });
138
+
139
+ test('createWorkspaceInvitation queues the teammate fallback when no inviter display is given', async () => {
140
+ const inviter = userFactory.build();
141
+ const workspace = await workspaceFactory.create();
142
+ await createMembership({
143
+ userId: inviter.userId,
144
+ userEmail: inviter.email,
145
+ userName: inviter.name,
146
+ workspaceId: workspace.id,
147
+ });
148
+ const email = `invitee-${crypto.randomUUID()}@example.com`;
149
+
150
+ await createWorkspaceInvitation({
151
+ workspaceId: workspace.id,
152
+ email,
153
+ invitedByUserId: inviter.userId,
154
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
155
+ });
156
+
157
+ const payload = await latestInvitationPayload(email);
158
+ expect(captured).toHaveLength(0);
159
+ expect(payload.inviterName).toBe('A teammate');
160
+ });
161
+
162
+ test('createWorkspaceInvitation queues fallbacks for blank display values', async () => {
163
+ const inviter = userFactory.build();
164
+ const workspace = await workspaceFactory.create({name: ' '});
165
+ await createMembership({
166
+ userId: inviter.userId,
167
+ userEmail: inviter.email,
168
+ userName: inviter.name,
169
+ workspaceId: workspace.id,
170
+ });
171
+ const email = `invitee-${crypto.randomUUID()}@example.com`;
172
+
173
+ await createWorkspaceInvitation({
174
+ workspaceId: workspace.id,
175
+ email,
176
+ invitedByUserId: inviter.userId,
177
+ invitedByDisplay: ' ',
178
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
179
+ });
180
+
181
+ const payload = await latestInvitationPayload(email);
182
+ expect(captured).toHaveLength(0);
183
+ expect(payload.workspaceName).toBe('your workspace');
184
+ expect(payload.inviterName).toBe('A teammate');
185
+ });
186
+
187
+ test('createWorkspaceInvitation rejects duplicate open invitations', async () => {
188
+ const inviter = userFactory.build();
189
+ const workspace = await workspaceFactory.create();
190
+ await createMembership({
191
+ userId: inviter.userId,
192
+ userEmail: inviter.email,
193
+ userName: inviter.name,
194
+ workspaceId: workspace.id,
195
+ });
196
+ const email = `duplicate-${crypto.randomUUID()}@example.com`;
197
+ await createWorkspaceInvitation({
198
+ workspaceId: workspace.id,
199
+ email,
200
+ invitedByUserId: inviter.userId,
201
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
202
+ });
203
+
204
+ const promise = createWorkspaceInvitation({
205
+ workspaceId: workspace.id,
206
+ email,
207
+ invitedByUserId: inviter.userId,
208
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
209
+ });
210
+
211
+ await expect(promise).rejects.toBeInstanceOf(OpenInvitationExistsError);
212
+ expect(captured).toHaveLength(0);
213
+ expect(await invitationEventsTo(email)).toHaveLength(1);
214
+ });
215
+
216
+ test('acceptWorkspaceInvitation accepts once and enforces the invited email', async () => {
217
+ const inviter = userFactory.build();
218
+ const invitee = userFactory.build();
219
+ const workspace = await workspaceFactory.create();
220
+ await createMembership({
221
+ userId: inviter.userId,
222
+ userEmail: inviter.email,
223
+ userName: inviter.name,
224
+ workspaceId: workspace.id,
225
+ });
226
+ await createWorkspaceInvitation({
227
+ workspaceId: workspace.id,
228
+ email: invitee.email,
229
+ invitedByUserId: inviter.userId,
230
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
231
+ });
232
+ const token = extractToken((await latestInvitationPayload(invitee.email)).inviteLink);
233
+
234
+ const wrongEmail = acceptWorkspaceInvitation({
235
+ token,
236
+ userId: invitee.userId,
237
+ email: `wrong-${crypto.randomUUID()}@example.com`,
238
+ });
239
+ await expect(wrongEmail).rejects.toBeInstanceOf(InvitationEmailMismatchError);
240
+
241
+ const result = await acceptWorkspaceInvitation({
242
+ token,
243
+ userId: invitee.userId,
244
+ email: invitee.email,
245
+ });
246
+ expect(result.membership.workspaceId).toBe(workspace.id);
247
+
248
+ const reused = acceptWorkspaceInvitation({token, userId: invitee.userId, email: invitee.email});
249
+ await expect(reused).rejects.toBeInstanceOf(TokenAlreadyUsedError);
250
+ });
251
+ });
@@ -0,0 +1,191 @@
1
+ import type {UserContextMembership} from '@shipfox/api-auth-context';
2
+ import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
3
+ import {config} from '#config.js';
4
+ import {
5
+ type AcceptInvitationResult,
6
+ acceptInvitation,
7
+ createInvitation,
8
+ findInvitationById,
9
+ findInvitationByToken,
10
+ listOpenInvitationsByWorkspace,
11
+ revokeInvitation,
12
+ } from '#db/invitations.js';
13
+ import {getWorkspaceById} from '#db/workspaces.js';
14
+ import type {Invitation} from './entities/invitation.js';
15
+ import {
16
+ InvitationEmailMismatchError,
17
+ InvitationNotFoundError,
18
+ InvitationWorkspaceMismatchError,
19
+ TokenAlreadyUsedError,
20
+ TokenExpiredError,
21
+ TokenInvalidError,
22
+ } from './errors.js';
23
+ import {requireWorkspaceMembership} from './workspaces.js';
24
+
25
+ const INVITATION_TTL_DAYS = 7;
26
+
27
+ function daysFromNow(days: number): Date {
28
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
29
+ }
30
+
31
+ function nonBlankOrFallback(value: string | null | undefined, fallback: string): string {
32
+ const trimmed = value?.trim();
33
+ return trimmed ? trimmed : fallback;
34
+ }
35
+
36
+ export async function createWorkspaceInvitation(params: {
37
+ workspaceId: string;
38
+ email: string;
39
+ invitedByUserId: string;
40
+ invitedByDisplay?: string | null;
41
+ invitedByMemberships: ReadonlyArray<UserContextMembership>;
42
+ }): Promise<Invitation> {
43
+ const {workspace} = await requireWorkspaceMembership({
44
+ workspaceId: params.workspaceId,
45
+ userId: params.invitedByUserId,
46
+ memberships: params.invitedByMemberships,
47
+ });
48
+
49
+ const rawToken = generateOpaqueToken('invitation');
50
+ const invitation = await createInvitation({
51
+ workspaceId: params.workspaceId,
52
+ email: params.email,
53
+ hashedToken: hashOpaqueToken(rawToken),
54
+ expiresAt: daysFromNow(INVITATION_TTL_DAYS),
55
+ invitedByUserId: params.invitedByUserId,
56
+ invitedByDisplay: params.invitedByDisplay ?? null,
57
+ sendEmail: {
58
+ workspaceName: nonBlankOrFallback(workspace.name, 'your workspace'),
59
+ inviterName: nonBlankOrFallback(params.invitedByDisplay, 'A teammate'),
60
+ inviteLink: `${config.CLIENT_BASE_URL}/invitations/accept?token=${rawToken}`,
61
+ },
62
+ });
63
+
64
+ return invitation;
65
+ }
66
+
67
+ export async function peekInvitationByRawToken(params: {
68
+ token: string;
69
+ }): Promise<Invitation | undefined> {
70
+ return await findInvitationByToken({hashedToken: hashOpaqueToken(params.token)});
71
+ }
72
+
73
+ export type PreviewInvitationResult =
74
+ | {
75
+ status: 'pending';
76
+ workspaceId: string;
77
+ workspaceName: string;
78
+ email: string;
79
+ invitedByDisplay: string | null;
80
+ expiresAt: Date;
81
+ }
82
+ | {status: 'expired'; workspaceName: string; expiresAt: Date}
83
+ | {status: 'already_used'; workspaceName: string}
84
+ | {status: 'invalid'};
85
+
86
+ export async function previewInvitation(params: {token: string}): Promise<PreviewInvitationResult> {
87
+ const invitation = await peekInvitationByRawToken({token: params.token});
88
+ if (!invitation) {
89
+ return {status: 'invalid'};
90
+ }
91
+
92
+ const workspace = await getWorkspaceById(invitation.workspaceId);
93
+ if (!workspace) {
94
+ // Workspace cascade-deleted — the invitation row is orphaned. Treat as invalid.
95
+ return {status: 'invalid'};
96
+ }
97
+
98
+ if (invitation.acceptedAt !== null) {
99
+ return {status: 'already_used', workspaceName: workspace.name};
100
+ }
101
+
102
+ if (invitation.expiresAt.getTime() <= Date.now()) {
103
+ return {
104
+ status: 'expired',
105
+ workspaceName: workspace.name,
106
+ expiresAt: invitation.expiresAt,
107
+ };
108
+ }
109
+
110
+ return {
111
+ status: 'pending',
112
+ workspaceId: workspace.id,
113
+ workspaceName: workspace.name,
114
+ email: invitation.email,
115
+ invitedByDisplay: invitation.invitedByDisplay,
116
+ expiresAt: invitation.expiresAt,
117
+ };
118
+ }
119
+
120
+ export async function acceptWorkspaceInvitation(params: {
121
+ token: string;
122
+ userId: string;
123
+ email: string;
124
+ name?: string | null | undefined;
125
+ }): Promise<AcceptInvitationResult> {
126
+ const invitation = await peekInvitationByRawToken({token: params.token});
127
+ if (!invitation) {
128
+ throw new TokenInvalidError('Invitation token is invalid');
129
+ }
130
+
131
+ if (invitation.acceptedAt !== null) {
132
+ throw new TokenAlreadyUsedError();
133
+ }
134
+
135
+ if (invitation.expiresAt.getTime() <= Date.now()) {
136
+ throw new TokenExpiredError();
137
+ }
138
+
139
+ if (params.email !== invitation.email) {
140
+ throw new InvitationEmailMismatchError();
141
+ }
142
+
143
+ const result = await acceptInvitation({
144
+ invitationId: invitation.id,
145
+ acceptedByUserId: params.userId,
146
+ acceptedByUserName: params.name,
147
+ });
148
+ if (!result) {
149
+ throw new TokenInvalidError('Invitation is no longer valid');
150
+ }
151
+
152
+ return result;
153
+ }
154
+
155
+ export async function listWorkspaceInvitations(params: {
156
+ workspaceId: string;
157
+ requesterUserId: string;
158
+ requesterMemberships: ReadonlyArray<UserContextMembership>;
159
+ }): Promise<Invitation[]> {
160
+ await requireWorkspaceMembership({
161
+ workspaceId: params.workspaceId,
162
+ userId: params.requesterUserId,
163
+ memberships: params.requesterMemberships,
164
+ });
165
+
166
+ return listOpenInvitationsByWorkspace({workspaceId: params.workspaceId});
167
+ }
168
+
169
+ export async function revokeWorkspaceInvitation(params: {
170
+ workspaceId: string;
171
+ requesterUserId: string;
172
+ requesterMemberships: ReadonlyArray<UserContextMembership>;
173
+ invitationId: string;
174
+ }): Promise<void> {
175
+ await requireWorkspaceMembership({
176
+ workspaceId: params.workspaceId,
177
+ userId: params.requesterUserId,
178
+ memberships: params.requesterMemberships,
179
+ });
180
+
181
+ const invitation = await findInvitationById({id: params.invitationId});
182
+ if (!invitation) {
183
+ throw new InvitationNotFoundError(params.invitationId);
184
+ }
185
+
186
+ if (invitation.workspaceId !== params.workspaceId) {
187
+ throw new InvitationWorkspaceMismatchError();
188
+ }
189
+
190
+ await revokeInvitation({invitationId: params.invitationId});
191
+ }
@@ -0,0 +1,77 @@
1
+ import {listMembershipsByWorkspace} from '#db/memberships.js';
2
+ import {userFactory, workspaceFactory} from '#test/index.js';
3
+ import {WorkspaceNotFoundError} from './errors.js';
4
+ import {ensureMembership} from './memberships.js';
5
+
6
+ describe('ensureMembership', () => {
7
+ test('creates a membership', async () => {
8
+ const user = userFactory.build();
9
+ const workspace = await workspaceFactory.create();
10
+
11
+ const membership = await ensureMembership({
12
+ userId: user.userId,
13
+ userEmail: user.email,
14
+ userName: user.name,
15
+ workspaceId: workspace.id,
16
+ });
17
+
18
+ expect(membership).toMatchObject({
19
+ userId: user.userId,
20
+ userEmail: user.email,
21
+ userName: user.name,
22
+ workspaceId: workspace.id,
23
+ });
24
+ });
25
+
26
+ test('rejects a missing workspace with WorkspaceNotFoundError', async () => {
27
+ const user = userFactory.build();
28
+
29
+ const membership = ensureMembership({
30
+ userId: user.userId,
31
+ userEmail: user.email,
32
+ userName: user.name,
33
+ workspaceId: crypto.randomUUID(),
34
+ });
35
+
36
+ await expect(membership).rejects.toBeInstanceOf(WorkspaceNotFoundError);
37
+ });
38
+
39
+ test('returns the existing membership without refreshing its profile snapshot', async () => {
40
+ const user = userFactory.build();
41
+ const workspace = await workspaceFactory.create();
42
+ const first = await ensureMembership({
43
+ userId: user.userId,
44
+ userEmail: user.email,
45
+ userName: 'Original Name',
46
+ workspaceId: workspace.id,
47
+ });
48
+ const second = await ensureMembership({
49
+ userId: user.userId,
50
+ userEmail: `replacement-${crypto.randomUUID()}@example.com`,
51
+ userName: 'Replacement Name',
52
+ workspaceId: workspace.id,
53
+ });
54
+
55
+ expect(second).toEqual(first);
56
+ });
57
+
58
+ test('uses the database constraint to converge concurrent calls on one membership', async () => {
59
+ const user = userFactory.build();
60
+ const workspace = await workspaceFactory.create();
61
+
62
+ const results = await Promise.all(
63
+ Array.from({length: 8}, () =>
64
+ ensureMembership({
65
+ userId: user.userId,
66
+ userEmail: user.email,
67
+ userName: user.name,
68
+ workspaceId: workspace.id,
69
+ }),
70
+ ),
71
+ );
72
+ const memberships = await listMembershipsByWorkspace({workspaceId: workspace.id});
73
+
74
+ expect(new Set(results.map((membership) => membership.id)).size).toBe(1);
75
+ expect(memberships).toHaveLength(1);
76
+ });
77
+ });
@@ -0,0 +1,17 @@
1
+ import {
2
+ type EnsureMembershipParams,
3
+ ensureMembership as ensureDbMembership,
4
+ } from '#db/memberships.js';
5
+ import type {Membership} from './entities/membership.js';
6
+ import {getWorkspace} from './workspaces.js';
7
+
8
+ export type {EnsureMembershipParams} from '#db/memberships.js';
9
+
10
+ /**
11
+ * Ensures an external identity has a workspace membership without refreshing
12
+ * the profile snapshot stored on an existing membership.
13
+ */
14
+ export async function ensureMembership(params: EnsureMembershipParams): Promise<Membership> {
15
+ await getWorkspace({workspaceId: params.workspaceId});
16
+ return await ensureDbMembership(params);
17
+ }