@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,74 @@
1
+ const metricMocks = vi.hoisted(() => {
2
+ const counters = new Map<string, {add: ReturnType<typeof vi.fn>}>();
3
+ const createCounter = vi.fn((name: string) => {
4
+ const counter = {add: vi.fn()};
5
+ counters.set(name, counter);
6
+ return counter;
7
+ });
8
+
9
+ return {counters, createCounter};
10
+ });
11
+
12
+ vi.mock('@shipfox/node-opentelemetry', () => ({
13
+ instanceMetrics: {
14
+ getMeter: () => ({
15
+ createCounter: metricMocks.createCounter,
16
+ }),
17
+ },
18
+ }));
19
+
20
+ function counterAdd(name: string): ReturnType<typeof vi.fn> {
21
+ const counter = metricMocks.counters.get(name);
22
+ if (!counter) throw new Error(`Missing counter: ${name}`);
23
+ return counter.add;
24
+ }
25
+
26
+ let metrics: typeof import('./instance.js');
27
+
28
+ describe('workspace metrics', () => {
29
+ beforeEach(async () => {
30
+ vi.resetModules();
31
+ metricMocks.counters.clear();
32
+ metrics = await import('./instance.js');
33
+ });
34
+
35
+ it('records workspace creation', () => {
36
+ metrics.recordWorkspaceCreated();
37
+
38
+ expect(counterAdd('workspaces_created')).toHaveBeenCalledWith(1);
39
+ });
40
+
41
+ it('records membership changes with bounded action labels', () => {
42
+ metrics.recordWorkspaceMembershipChanged('removed');
43
+
44
+ expect(counterAdd('workspaces_membership_changed')).toHaveBeenCalledWith(1, {
45
+ action: 'removed',
46
+ });
47
+ });
48
+
49
+ it('records invitation creation with email request labels', () => {
50
+ metrics.recordWorkspaceInvitationCreated('requested');
51
+
52
+ expect(counterAdd('workspaces_invitation_created')).toHaveBeenCalledWith(1, {
53
+ email_requested: 'requested',
54
+ });
55
+ });
56
+
57
+ it('records invitation acceptance outcomes', () => {
58
+ metrics.recordWorkspaceInvitationAccepted('already_member');
59
+
60
+ expect(counterAdd('workspaces_invitation_accepted')).toHaveBeenCalledWith(1, {
61
+ outcome: 'already_member',
62
+ });
63
+ });
64
+
65
+ it('does not let metric failures affect callers', () => {
66
+ counterAdd('workspaces_membership_changed').mockImplementationOnce(() => {
67
+ throw new Error('metrics unavailable');
68
+ });
69
+
70
+ const act = () => metrics.recordWorkspaceMembershipChanged('added');
71
+
72
+ expect(act).not.toThrow();
73
+ });
74
+ });
@@ -0,0 +1,57 @@
1
+ import {instanceMetrics} from '@shipfox/node-opentelemetry';
2
+
3
+ export type WorkspacesInvitationEmailRequested = 'requested' | 'skipped';
4
+ export type WorkspacesMembershipChangeAction = 'added' | 'removed';
5
+ export type WorkspacesInvitationAcceptOutcome = 'added' | 'already_member';
6
+
7
+ const meter = instanceMetrics.getMeter('workspaces');
8
+
9
+ const workspaceCreatedCount = meter.createCounter<Record<string, never>>('workspaces_created', {
10
+ description: 'Workspaces created',
11
+ });
12
+
13
+ const membershipChangedCount = meter.createCounter<{
14
+ action: WorkspacesMembershipChangeAction;
15
+ }>('workspaces_membership_changed', {
16
+ description: 'Workspace membership changes by action',
17
+ });
18
+
19
+ const invitationCreatedCount = meter.createCounter<{
20
+ email_requested: WorkspacesInvitationEmailRequested;
21
+ }>('workspaces_invitation_created', {
22
+ description: 'Workspace invitations created by whether an invitation email was requested',
23
+ });
24
+
25
+ const invitationAcceptedCount = meter.createCounter<{
26
+ outcome: WorkspacesInvitationAcceptOutcome;
27
+ }>('workspaces_invitation_accepted', {
28
+ description: 'Workspace invitations accepted by membership outcome',
29
+ });
30
+
31
+ function recordMetric(record: () => void): void {
32
+ try {
33
+ record();
34
+ } catch {
35
+ // Metrics must not affect workspace mutations.
36
+ }
37
+ }
38
+
39
+ export function recordWorkspaceCreated(): void {
40
+ recordMetric(() => workspaceCreatedCount.add(1));
41
+ }
42
+
43
+ export function recordWorkspaceMembershipChanged(action: WorkspacesMembershipChangeAction): void {
44
+ recordMetric(() => membershipChangedCount.add(1, {action}));
45
+ }
46
+
47
+ export function recordWorkspaceInvitationCreated(
48
+ emailRequested: WorkspacesInvitationEmailRequested,
49
+ ): void {
50
+ recordMetric(() => invitationCreatedCount.add(1, {email_requested: emailRequested}));
51
+ }
52
+
53
+ export function recordWorkspaceInvitationAccepted(
54
+ outcome: WorkspacesInvitationAcceptOutcome,
55
+ ): void {
56
+ recordMetric(() => invitationAcceptedCount.add(1, {outcome}));
57
+ }
@@ -0,0 +1,26 @@
1
+ import {getServiceMetricsProvider} from '@shipfox/node-opentelemetry';
2
+ import {getWorkspaceServiceMetrics} from '#db/workspaces.js';
3
+
4
+ export function registerWorkspacesServiceMetrics(): void {
5
+ const meter = getServiceMetricsProvider().getMeter('workspaces');
6
+
7
+ const activeWorkspaces = meter.createObservableGauge('workspaces_active', {
8
+ description: 'Workspaces currently marked active',
9
+ });
10
+ const memberships = meter.createObservableGauge('workspaces_memberships', {
11
+ description: 'Workspace memberships currently present',
12
+ });
13
+ const openInvitations = meter.createObservableGauge('workspaces_open_invitations', {
14
+ description: 'Workspace invitations currently unaccepted and unexpired',
15
+ });
16
+
17
+ meter.addBatchObservableCallback(
18
+ async (observer) => {
19
+ const metrics = await getWorkspaceServiceMetrics();
20
+ observer.observe(activeWorkspaces, metrics.activeWorkspaces);
21
+ observer.observe(memberships, metrics.memberships);
22
+ observer.observe(openInvitations, metrics.openInvitations);
23
+ },
24
+ [activeWorkspaces, memberships, openInvitations],
25
+ );
26
+ }
@@ -0,0 +1,3 @@
1
+ export {toInvitationDto} from './invitation.js';
2
+ export {toMembershipWithUserDto, toMembershipWithWorkspaceDto} from './membership.js';
3
+ export {toWorkspaceDto} from './workspace.js';
@@ -0,0 +1,16 @@
1
+ import type {InvitationDto} from '@shipfox/api-workspaces-dto';
2
+ import type {Invitation} from '#core/entities/invitation.js';
3
+
4
+ export function toInvitationDto(invitation: Invitation): InvitationDto {
5
+ return {
6
+ id: invitation.id,
7
+ workspace_id: invitation.workspaceId,
8
+ email: invitation.email,
9
+ expires_at: invitation.expiresAt.toISOString(),
10
+ accepted_at: invitation.acceptedAt ? invitation.acceptedAt.toISOString() : null,
11
+ invited_by_user_id: invitation.invitedByUserId,
12
+ invited_by_display: invitation.invitedByDisplay,
13
+ created_at: invitation.createdAt.toISOString(),
14
+ updated_at: invitation.updatedAt.toISOString(),
15
+ };
16
+ }
@@ -0,0 +1,27 @@
1
+ import type {MembershipWithUserDto, MembershipWithWorkspaceDto} from '@shipfox/api-workspaces-dto';
2
+ import type {MembershipWithUser, MembershipWithWorkspace} from '#db/memberships.js';
3
+
4
+ export function toMembershipWithWorkspaceDto(
5
+ membership: MembershipWithWorkspace,
6
+ ): MembershipWithWorkspaceDto {
7
+ return {
8
+ id: membership.id,
9
+ user_id: membership.userId,
10
+ workspace_id: membership.workspaceId,
11
+ workspace_name: membership.workspaceName,
12
+ created_at: membership.createdAt.toISOString(),
13
+ updated_at: membership.updatedAt.toISOString(),
14
+ };
15
+ }
16
+
17
+ export function toMembershipWithUserDto(membership: MembershipWithUser): MembershipWithUserDto {
18
+ return {
19
+ id: membership.id,
20
+ user_id: membership.userId,
21
+ workspace_id: membership.workspaceId,
22
+ user_email: membership.userEmail,
23
+ user_name: membership.userName,
24
+ created_at: membership.createdAt.toISOString(),
25
+ updated_at: membership.updatedAt.toISOString(),
26
+ };
27
+ }
@@ -0,0 +1,13 @@
1
+ import type {WorkspaceDto} from '@shipfox/api-workspaces-dto';
2
+ import type {Workspace} from '#core/entities/workspace.js';
3
+
4
+ export function toWorkspaceDto(workspace: Workspace): WorkspaceDto {
5
+ return {
6
+ id: workspace.id,
7
+ name: workspace.name,
8
+ status: workspace.status,
9
+ settings: workspace.settings,
10
+ created_at: workspace.createdAt.toISOString(),
11
+ updated_at: workspace.updatedAt.toISOString(),
12
+ };
13
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ e2eCreateInvitationBodySchema,
3
+ e2eCreateInvitationResponseSchema,
4
+ } from '@shipfox/api-workspaces-dto';
5
+ import {defineRoute} from '@shipfox/node-fastify';
6
+ import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
7
+ import {createInvitation} from '#db/invitations.js';
8
+ import {toInvitationDto} from '#presentation/dto/index.js';
9
+
10
+ const INVITATION_TTL_DAYS = 7;
11
+
12
+ function daysFromNow(days: number): Date {
13
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
14
+ }
15
+
16
+ export const createE2eInvitationRoute = defineRoute({
17
+ method: 'POST',
18
+ path: '/invitations',
19
+ description: 'Create a workspace invitation for E2E tests.',
20
+ schema: {
21
+ body: e2eCreateInvitationBodySchema,
22
+ response: {
23
+ 201: e2eCreateInvitationResponseSchema,
24
+ },
25
+ },
26
+ handler: async (request, reply) => {
27
+ const rawToken = generateOpaqueToken('invitation');
28
+ const invitation = await createInvitation({
29
+ workspaceId: request.body.workspace_id,
30
+ email: request.body.email,
31
+ hashedToken: hashOpaqueToken(rawToken),
32
+ expiresAt: daysFromNow(INVITATION_TTL_DAYS),
33
+ invitedByUserId: request.body.invited_by_user_id,
34
+ invitedByDisplay: request.body.invited_by_display ?? null,
35
+ skipEmail: true,
36
+ });
37
+
38
+ reply.code(201);
39
+ return {
40
+ invitation: toInvitationDto(invitation),
41
+ raw_token: rawToken,
42
+ };
43
+ },
44
+ });
@@ -0,0 +1,30 @@
1
+ import {
2
+ e2eCreateWorkspaceBodySchema,
3
+ e2eCreateWorkspaceResponseSchema,
4
+ } from '@shipfox/api-workspaces-dto';
5
+ import {defineRoute} from '@shipfox/node-fastify';
6
+ import {createWorkspaceForUser} from '#core/index.js';
7
+ import {toWorkspaceDto} from '#presentation/dto/index.js';
8
+
9
+ export const createE2eWorkspaceRoute = defineRoute({
10
+ method: 'POST',
11
+ path: '/',
12
+ description: 'Create a workspace for an existing user for E2E tests.',
13
+ schema: {
14
+ body: e2eCreateWorkspaceBodySchema,
15
+ response: {
16
+ 201: e2eCreateWorkspaceResponseSchema,
17
+ },
18
+ },
19
+ handler: async (request, reply) => {
20
+ const workspace = await createWorkspaceForUser({
21
+ name: request.body.name,
22
+ userId: request.body.user_id,
23
+ userEmail: request.body.user_email,
24
+ userName: request.body.user_name,
25
+ });
26
+
27
+ reply.code(201);
28
+ return toWorkspaceDto(workspace);
29
+ },
30
+ });
@@ -0,0 +1,8 @@
1
+ import type {RouteGroup} from '@shipfox/node-fastify';
2
+ import {createE2eInvitationRoute} from './create-invitation.js';
3
+ import {createE2eWorkspaceRoute} from './create-workspace.js';
4
+
5
+ export const workspacesE2eRoutes: RouteGroup = {
6
+ prefix: '/workspaces',
7
+ routes: [createE2eWorkspaceRoute, createE2eInvitationRoute],
8
+ };
@@ -0,0 +1 @@
1
+ export {workspacesRoutes as routes} from './routes/index.js';
@@ -0,0 +1,23 @@
1
+ import type {RouteGroup} from '@shipfox/node-fastify';
2
+ import {invitationsAcceptGroup, invitationsWorkspaceScopedRoutes} from './invitations/index.js';
3
+ import {memberRoutes} from './members/index.js';
4
+ import {createWorkspaceRoute, listUserWorkspacesRoute} from './workspaces/index.js';
5
+
6
+ export const workspacesRoutes: RouteGroup[] = [
7
+ invitationsAcceptGroup,
8
+ {
9
+ prefix: '/workspaces',
10
+ routes: [
11
+ listUserWorkspacesRoute,
12
+ createWorkspaceRoute,
13
+ {
14
+ prefix: '/:workspaceId/members',
15
+ routes: memberRoutes,
16
+ },
17
+ {
18
+ prefix: '/:workspaceId/invitations',
19
+ routes: invitationsWorkspaceScopedRoutes,
20
+ },
21
+ ],
22
+ },
23
+ ];
@@ -0,0 +1,154 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {
3
+ createExpiredInvite,
4
+ createInvite,
5
+ createWorkspace,
6
+ createWorkspacesTestApp,
7
+ resetCapturedMail,
8
+ signupVerifyLogin,
9
+ } from '#test/routes.js';
10
+
11
+ describe('POST /invitations/accept', () => {
12
+ let app: FastifyInstance;
13
+
14
+ beforeAll(async () => {
15
+ app = await createWorkspacesTestApp();
16
+ });
17
+
18
+ beforeEach(() => {
19
+ resetCapturedMail();
20
+ });
21
+
22
+ afterAll(async () => {
23
+ await app.close();
24
+ });
25
+
26
+ test('returns 201 and creates a membership for a new member', async () => {
27
+ const owner = await signupVerifyLogin(app, 'accept-owner');
28
+ const guest = await signupVerifyLogin(app, 'accept-guest');
29
+ const guestName = 'Accepted Guest';
30
+ const guestToken = `user:${guest.userId}:${guest.email}:${encodeURIComponent(guestName)}`;
31
+ const workspaceId = await createWorkspace(app, owner.token);
32
+ const invite = await createInvite(app, {token: owner.token, workspaceId, email: guest.email});
33
+
34
+ const res = await app.inject({
35
+ method: 'POST',
36
+ url: '/invitations/accept',
37
+ headers: {authorization: `Bearer ${guestToken}`},
38
+ payload: {token: invite.rawToken},
39
+ });
40
+ const members = await app.inject({
41
+ method: 'GET',
42
+ url: `/workspaces/${workspaceId}/members`,
43
+ headers: {authorization: `Bearer ${owner.token}`},
44
+ });
45
+
46
+ expect(res.statusCode).toBe(201);
47
+ expect(res.json().already_member).toBe(false);
48
+ expect(res.json().membership).toMatchObject({
49
+ user_id: guest.userId,
50
+ workspace_id: workspaceId,
51
+ });
52
+ expect(members.statusCode).toBe(200);
53
+ expect(members.json().members).toEqual(
54
+ expect.arrayContaining([
55
+ expect.objectContaining({
56
+ user_email: guest.email,
57
+ user_name: guestName,
58
+ }),
59
+ ]),
60
+ );
61
+ });
62
+
63
+ test('returns 200 when the invitee is already a member', async () => {
64
+ const owner = await signupVerifyLogin(app, 'accept-existing-owner');
65
+ const workspaceId = await createWorkspace(app, owner.token);
66
+ const invite = await createInvite(app, {token: owner.token, workspaceId, email: owner.email});
67
+
68
+ const res = await app.inject({
69
+ method: 'POST',
70
+ url: '/invitations/accept',
71
+ headers: {authorization: `Bearer ${owner.token}`},
72
+ payload: {token: invite.rawToken},
73
+ });
74
+
75
+ expect(res.statusCode).toBe(200);
76
+ expect(res.json().already_member).toBe(true);
77
+ });
78
+
79
+ test('transforms invalid token into 410', async () => {
80
+ const guest = await signupVerifyLogin(app, 'accept-invalid');
81
+
82
+ const res = await app.inject({
83
+ method: 'POST',
84
+ url: '/invitations/accept',
85
+ headers: {authorization: `Bearer ${guest.token}`},
86
+ payload: {token: 'not-a-real-token'},
87
+ });
88
+
89
+ expect(res.statusCode).toBe(410);
90
+ expect(res.json().code).toBe('token-invalid');
91
+ });
92
+
93
+ test('transforms already used token into 410', async () => {
94
+ const owner = await signupVerifyLogin(app, 'accept-used-owner');
95
+ const guest = await signupVerifyLogin(app, 'accept-used-guest');
96
+ const workspaceId = await createWorkspace(app, owner.token);
97
+ const invite = await createInvite(app, {token: owner.token, workspaceId, email: guest.email});
98
+ await app.inject({
99
+ method: 'POST',
100
+ url: '/invitations/accept',
101
+ headers: {authorization: `Bearer ${guest.token}`},
102
+ payload: {token: invite.rawToken},
103
+ });
104
+
105
+ const res = await app.inject({
106
+ method: 'POST',
107
+ url: '/invitations/accept',
108
+ headers: {authorization: `Bearer ${guest.token}`},
109
+ payload: {token: invite.rawToken},
110
+ });
111
+
112
+ expect(res.statusCode).toBe(410);
113
+ expect(res.json().code).toBe('token-already-used');
114
+ });
115
+
116
+ test('transforms expired token into 410', async () => {
117
+ const owner = await signupVerifyLogin(app, 'accept-expired-owner');
118
+ const guest = await signupVerifyLogin(app, 'accept-expired-guest');
119
+ const workspaceId = await createWorkspace(app, owner.token);
120
+ const expiredToken = await createExpiredInvite({
121
+ workspaceId,
122
+ email: guest.email,
123
+ invitedByUserId: owner.userId,
124
+ });
125
+
126
+ const res = await app.inject({
127
+ method: 'POST',
128
+ url: '/invitations/accept',
129
+ headers: {authorization: `Bearer ${guest.token}`},
130
+ payload: {token: expiredToken},
131
+ });
132
+
133
+ expect(res.statusCode).toBe(410);
134
+ expect(res.json().code).toBe('token-expired');
135
+ });
136
+
137
+ test('transforms email mismatch into 403', async () => {
138
+ const owner = await signupVerifyLogin(app, 'accept-mismatch-owner');
139
+ const invited = await signupVerifyLogin(app, 'accept-mismatch-invited');
140
+ const other = await signupVerifyLogin(app, 'accept-mismatch-other');
141
+ const workspaceId = await createWorkspace(app, owner.token);
142
+ const invite = await createInvite(app, {token: owner.token, workspaceId, email: invited.email});
143
+
144
+ const res = await app.inject({
145
+ method: 'POST',
146
+ url: '/invitations/accept',
147
+ headers: {authorization: `Bearer ${other.token}`},
148
+ payload: {token: invite.rawToken},
149
+ });
150
+
151
+ expect(res.statusCode).toBe(403);
152
+ expect(res.json().code).toBe('forbidden');
153
+ });
154
+ });
@@ -0,0 +1,70 @@
1
+ import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ acceptInvitationBodySchema,
4
+ acceptInvitationResponseSchema,
5
+ } from '@shipfox/api-workspaces-dto';
6
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
+ import {
8
+ InvitationEmailMismatchError,
9
+ TokenAlreadyUsedError,
10
+ TokenExpiredError,
11
+ TokenInvalidError,
12
+ } from '#core/errors.js';
13
+ import {acceptWorkspaceInvitation} from '#core/index.js';
14
+
15
+ export const acceptInvitationRoute = defineRoute({
16
+ method: 'POST',
17
+ path: '/accept',
18
+ description: 'Accept an invitation to join a workspace.',
19
+ auth: AUTH_USER,
20
+ schema: {
21
+ body: acceptInvitationBodySchema,
22
+ response: {
23
+ 200: acceptInvitationResponseSchema,
24
+ 201: acceptInvitationResponseSchema,
25
+ },
26
+ },
27
+ errorHandler: (error) => {
28
+ if (error instanceof TokenInvalidError) {
29
+ throw new ClientError('Invitation token is invalid', 'token-invalid', {status: 410});
30
+ }
31
+ if (error instanceof TokenAlreadyUsedError) {
32
+ throw new ClientError('Invitation has already been accepted', 'token-already-used', {
33
+ status: 410,
34
+ });
35
+ }
36
+ if (error instanceof TokenExpiredError) {
37
+ throw new ClientError('Invitation has expired', 'token-expired', {status: 410});
38
+ }
39
+ if (error instanceof InvitationEmailMismatchError) {
40
+ throw new ClientError('Invitation email does not match authenticated user', 'forbidden', {
41
+ status: 403,
42
+ });
43
+ }
44
+ throw error;
45
+ },
46
+ handler: async (request, reply) => {
47
+ const client = getUserContext(request);
48
+ if (!client) {
49
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
50
+ }
51
+
52
+ const {token} = request.body;
53
+ const result = await acceptWorkspaceInvitation({
54
+ token,
55
+ userId: client.userId,
56
+ email: client.email,
57
+ name: client.name,
58
+ });
59
+
60
+ reply.code(result.alreadyMember ? 200 : 201);
61
+ return {
62
+ membership: {
63
+ id: result.membership.id,
64
+ user_id: result.membership.userId,
65
+ workspace_id: result.membership.workspaceId,
66
+ },
67
+ already_member: result.alreadyMember,
68
+ };
69
+ },
70
+ });
@@ -0,0 +1,99 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {
3
+ capturedMail,
4
+ createInvite,
5
+ createWorkspace,
6
+ createWorkspacesTestApp,
7
+ invitationOutboxEventsTo,
8
+ resetCapturedMail,
9
+ signupVerifyLogin,
10
+ uniqueEmail,
11
+ } from '#test/routes.js';
12
+
13
+ describe('POST /workspaces/:workspaceId/invitations', () => {
14
+ let app: FastifyInstance;
15
+
16
+ beforeAll(async () => {
17
+ app = await createWorkspacesTestApp();
18
+ });
19
+
20
+ beforeEach(() => {
21
+ resetCapturedMail();
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await app.close();
26
+ });
27
+
28
+ test('returns 201 with an invitation for a workspace member', async () => {
29
+ const owner = await signupVerifyLogin(app, 'invite-create-owner');
30
+ const workspaceId = await createWorkspace(app, owner.token);
31
+ const inviteeEmail = uniqueEmail('invite-create');
32
+
33
+ const res = await app.inject({
34
+ method: 'POST',
35
+ url: `/workspaces/${workspaceId}/invitations`,
36
+ headers: {authorization: `Bearer ${owner.token}`},
37
+ payload: {email: inviteeEmail.toUpperCase()},
38
+ });
39
+
40
+ expect(res.statusCode).toBe(201);
41
+ expect(res.json()).toMatchObject({
42
+ workspace_id: workspaceId,
43
+ email: inviteeEmail,
44
+ accepted_at: null,
45
+ invited_by_user_id: owner.userId,
46
+ });
47
+ expect(await invitationOutboxEventsTo(inviteeEmail)).toHaveLength(1);
48
+ expect(capturedMail()).toHaveLength(0);
49
+ });
50
+
51
+ test('transforms duplicate open invitation into 409', async () => {
52
+ const owner = await signupVerifyLogin(app, 'invite-create-duplicate');
53
+ const workspaceId = await createWorkspace(app, owner.token);
54
+ const inviteeEmail = uniqueEmail('duplicate-invite');
55
+ await createInvite(app, {token: owner.token, workspaceId, email: inviteeEmail});
56
+
57
+ const res = await app.inject({
58
+ method: 'POST',
59
+ url: `/workspaces/${workspaceId}/invitations`,
60
+ headers: {authorization: `Bearer ${owner.token}`},
61
+ payload: {email: inviteeEmail},
62
+ });
63
+
64
+ expect(res.statusCode).toBe(409);
65
+ expect(res.json().code).toBe('open-invitation-exists');
66
+ expect(await invitationOutboxEventsTo(inviteeEmail)).toHaveLength(1);
67
+ });
68
+
69
+ test('transforms missing membership into 403', async () => {
70
+ const outsider = await signupVerifyLogin(app, 'invite-create-outsider');
71
+ const workspaceId = crypto.randomUUID();
72
+ const inviteeEmail = uniqueEmail('forbidden-invite');
73
+
74
+ const res = await app.inject({
75
+ method: 'POST',
76
+ url: `/workspaces/${workspaceId}/invitations`,
77
+ headers: {authorization: `Bearer ${outsider.token}`},
78
+ payload: {email: inviteeEmail},
79
+ });
80
+
81
+ expect(res.statusCode).toBe(403);
82
+ expect(res.json().code).toBe('forbidden');
83
+ expect(await invitationOutboxEventsTo(inviteeEmail)).toHaveLength(0);
84
+ });
85
+
86
+ test('returns 403 when caller has no claim to the workspace (whether or not it exists)', async () => {
87
+ const owner = await signupVerifyLogin(app, 'invite-create-missing-workspace');
88
+
89
+ const res = await app.inject({
90
+ method: 'POST',
91
+ url: `/workspaces/${crypto.randomUUID()}/invitations`,
92
+ headers: {authorization: `Bearer ${owner.token}`},
93
+ payload: {email: uniqueEmail('missing-workspace-invite')},
94
+ });
95
+
96
+ expect(res.statusCode).toBe(403);
97
+ expect(res.json().code).toBe('forbidden');
98
+ });
99
+ });