@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,60 @@
1
+ import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
2
+ import {createInvitationBodySchema, invitationDtoSchema} from '@shipfox/api-workspaces-dto';
3
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
+ import {z} from 'zod';
5
+ import {
6
+ MembershipRequiredError,
7
+ OpenInvitationExistsError,
8
+ WorkspaceInactiveError,
9
+ WorkspaceNotFoundError,
10
+ } from '#core/errors.js';
11
+ import {createWorkspaceInvitation} from '#core/index.js';
12
+ import {toInvitationDto} from '#presentation/dto/index.js';
13
+
14
+ export const createInvitationRoute = defineRoute({
15
+ method: 'POST',
16
+ path: '/',
17
+ description: 'Invite someone to join a workspace by email.',
18
+ auth: AUTH_USER,
19
+ schema: {
20
+ params: z.object({workspaceId: z.string().uuid()}),
21
+ body: createInvitationBodySchema,
22
+ response: {
23
+ 201: invitationDtoSchema,
24
+ },
25
+ },
26
+ errorHandler: (error) => {
27
+ if (error instanceof WorkspaceNotFoundError) {
28
+ throw new ClientError('Workspace not found', 'not-found', {status: 404});
29
+ }
30
+ if (error instanceof MembershipRequiredError) {
31
+ throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});
32
+ }
33
+ if (error instanceof OpenInvitationExistsError) {
34
+ throw new ClientError(error.message, 'open-invitation-exists', {status: 409});
35
+ }
36
+ if (error instanceof WorkspaceInactiveError) {
37
+ throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});
38
+ }
39
+ throw error;
40
+ },
41
+ handler: async (request, reply) => {
42
+ const client = getUserContext(request);
43
+ if (!client) {
44
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
45
+ }
46
+
47
+ const {workspaceId} = request.params;
48
+ const {email} = request.body;
49
+ const invitation = await createWorkspaceInvitation({
50
+ workspaceId,
51
+ email,
52
+ invitedByUserId: client.userId,
53
+ invitedByDisplay: client.name ?? 'A workspace admin',
54
+ invitedByMemberships: client.memberships,
55
+ });
56
+
57
+ reply.code(201);
58
+ return toInvitationDto(invitation);
59
+ },
60
+ });
@@ -0,0 +1,17 @@
1
+ import type {RouteDefinition, RouteGroup} from '@shipfox/node-fastify';
2
+ import {acceptInvitationRoute} from './accept.js';
3
+ import {createInvitationRoute} from './create.js';
4
+ import {listInvitationsRoute} from './list.js';
5
+ import {previewInvitationRoute} from './preview.js';
6
+ import {revokeInvitationRoute} from './revoke.js';
7
+
8
+ export const invitationsWorkspaceScopedRoutes: RouteDefinition[] = [
9
+ createInvitationRoute,
10
+ listInvitationsRoute,
11
+ revokeInvitationRoute,
12
+ ];
13
+
14
+ export const invitationsAcceptGroup: RouteGroup = {
15
+ prefix: '/invitations',
16
+ routes: [acceptInvitationRoute, previewInvitationRoute],
17
+ };
@@ -0,0 +1,94 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {
3
+ createExpiredInvite,
4
+ createInvite,
5
+ createWorkspace,
6
+ createWorkspacesTestApp,
7
+ resetCapturedMail,
8
+ signupVerifyLogin,
9
+ uniqueEmail,
10
+ } from '#test/routes.js';
11
+
12
+ describe('GET /workspaces/:workspaceId/invitations', () => {
13
+ let app: FastifyInstance;
14
+
15
+ beforeAll(async () => {
16
+ app = await createWorkspacesTestApp();
17
+ });
18
+
19
+ beforeEach(() => {
20
+ resetCapturedMail();
21
+ });
22
+
23
+ afterAll(async () => {
24
+ await app.close();
25
+ });
26
+
27
+ test('returns open invitations for a workspace member', async () => {
28
+ const owner = await signupVerifyLogin(app, 'invite-list-owner');
29
+ const workspaceId = await createWorkspace(app, owner.token);
30
+ const inviteeEmail = uniqueEmail('invite-list');
31
+ const invite = await createInvite(app, {token: owner.token, workspaceId, email: inviteeEmail});
32
+
33
+ const res = await app.inject({
34
+ method: 'GET',
35
+ url: `/workspaces/${workspaceId}/invitations`,
36
+ headers: {authorization: `Bearer ${owner.token}`},
37
+ });
38
+
39
+ expect(res.statusCode).toBe(200);
40
+ expect(res.json().invitations).toEqual([
41
+ expect.objectContaining({
42
+ id: invite.id,
43
+ workspace_id: workspaceId,
44
+ email: inviteeEmail,
45
+ }),
46
+ ]);
47
+ });
48
+
49
+ test('excludes expired unaccepted invitations', async () => {
50
+ const owner = await signupVerifyLogin(app, 'invite-list-expired-owner');
51
+ const workspaceId = await createWorkspace(app, owner.token);
52
+ await createExpiredInvite({
53
+ workspaceId,
54
+ email: uniqueEmail('invite-list-expired'),
55
+ invitedByUserId: owner.userId,
56
+ });
57
+
58
+ const res = await app.inject({
59
+ method: 'GET',
60
+ url: `/workspaces/${workspaceId}/invitations`,
61
+ headers: {authorization: `Bearer ${owner.token}`},
62
+ });
63
+
64
+ expect(res.statusCode).toBe(200);
65
+ expect(res.json().invitations).toEqual([]);
66
+ });
67
+
68
+ test('transforms missing membership into 403', async () => {
69
+ const outsider = await signupVerifyLogin(app, 'invite-list-outsider');
70
+ const workspaceId = crypto.randomUUID();
71
+
72
+ const res = await app.inject({
73
+ method: 'GET',
74
+ url: `/workspaces/${workspaceId}/invitations`,
75
+ headers: {authorization: `Bearer ${outsider.token}`},
76
+ });
77
+
78
+ expect(res.statusCode).toBe(403);
79
+ expect(res.json().code).toBe('forbidden');
80
+ });
81
+
82
+ test('returns 403 when caller has no claim to the workspace (whether or not it exists)', async () => {
83
+ const owner = await signupVerifyLogin(app, 'invite-list-missing-workspace');
84
+
85
+ const res = await app.inject({
86
+ method: 'GET',
87
+ url: `/workspaces/${crypto.randomUUID()}/invitations`,
88
+ headers: {authorization: `Bearer ${owner.token}`},
89
+ });
90
+
91
+ expect(res.statusCode).toBe(403);
92
+ expect(res.json().code).toBe('forbidden');
93
+ });
94
+ });
@@ -0,0 +1,53 @@
1
+ import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
2
+ import {listInvitationsResponseSchema} from '@shipfox/api-workspaces-dto';
3
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
+ import {z} from 'zod';
5
+ import {
6
+ MembershipRequiredError,
7
+ WorkspaceInactiveError,
8
+ WorkspaceNotFoundError,
9
+ } from '#core/errors.js';
10
+ import {listWorkspaceInvitations} from '#core/index.js';
11
+ import {toInvitationDto} from '#presentation/dto/index.js';
12
+
13
+ export const listInvitationsRoute = defineRoute({
14
+ method: 'GET',
15
+ path: '/',
16
+ description: 'List pending invitations for a workspace.',
17
+ auth: AUTH_USER,
18
+ schema: {
19
+ params: z.object({workspaceId: z.string().uuid()}),
20
+ response: {
21
+ 200: listInvitationsResponseSchema,
22
+ },
23
+ },
24
+ errorHandler: (error) => {
25
+ if (error instanceof WorkspaceNotFoundError) {
26
+ throw new ClientError('Workspace not found', 'not-found', {status: 404});
27
+ }
28
+ if (error instanceof MembershipRequiredError) {
29
+ throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});
30
+ }
31
+ if (error instanceof WorkspaceInactiveError) {
32
+ throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});
33
+ }
34
+ throw error;
35
+ },
36
+ handler: async (request) => {
37
+ const client = getUserContext(request);
38
+ if (!client) {
39
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
40
+ }
41
+
42
+ const {workspaceId} = request.params;
43
+ const invitations = await listWorkspaceInvitations({
44
+ workspaceId,
45
+ requesterUserId: client.userId,
46
+ requesterMemberships: client.memberships,
47
+ });
48
+
49
+ return {
50
+ invitations: invitations.map(toInvitationDto),
51
+ };
52
+ },
53
+ });
@@ -0,0 +1,140 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {
3
+ createExpiredInvite,
4
+ createInvite,
5
+ createWorkspace,
6
+ createWorkspacesTestApp,
7
+ resetCapturedMail,
8
+ signupVerifyLogin,
9
+ uniqueEmail,
10
+ } from '#test/routes.js';
11
+
12
+ describe('GET /invitations/preview', () => {
13
+ let app: FastifyInstance;
14
+
15
+ beforeAll(async () => {
16
+ app = await createWorkspacesTestApp();
17
+ });
18
+
19
+ beforeEach(() => {
20
+ resetCapturedMail();
21
+ });
22
+
23
+ afterAll(async () => {
24
+ await app.close();
25
+ });
26
+
27
+ test('returns pending invitation metadata without authentication', async () => {
28
+ const owner = await signupVerifyLogin(app, 'invite-preview-owner');
29
+ const workspaceId = await createWorkspace(app, owner.token, 'Acme');
30
+ const inviteeEmail = uniqueEmail('invite-preview');
31
+ const invite = await createInvite(app, {
32
+ token: owner.token,
33
+ workspaceId,
34
+ email: inviteeEmail,
35
+ });
36
+
37
+ const res = await app.inject({
38
+ method: 'GET',
39
+ url: `/invitations/preview?token=${encodeURIComponent(invite.rawToken)}`,
40
+ });
41
+
42
+ expect(res.statusCode).toBe(200);
43
+ expect(res.json()).toEqual(
44
+ expect.objectContaining({
45
+ status: 'pending',
46
+ workspace_id: workspaceId,
47
+ workspace_name: 'Acme',
48
+ email: inviteeEmail,
49
+ invited_by_display: 'A workspace admin',
50
+ expires_at: expect.any(String),
51
+ }),
52
+ );
53
+ });
54
+
55
+ test('surfaces the inviter display name when the owner has one', async () => {
56
+ const owner = await signupVerifyLogin(app, 'invite-preview-named-owner', {name: 'Owner User'});
57
+ const workspaceId = await createWorkspace(app, owner.token, 'Named Co');
58
+ const invite = await createInvite(app, {
59
+ token: owner.token,
60
+ workspaceId,
61
+ email: uniqueEmail('invite-preview-named'),
62
+ });
63
+
64
+ const res = await app.inject({
65
+ method: 'GET',
66
+ url: `/invitations/preview?token=${encodeURIComponent(invite.rawToken)}`,
67
+ });
68
+
69
+ expect(res.statusCode).toBe(200);
70
+ expect(res.json()).toEqual(
71
+ expect.objectContaining({
72
+ status: 'pending',
73
+ invited_by_display: 'Owner User',
74
+ }),
75
+ );
76
+ });
77
+
78
+ test('returns invalid for unknown tokens', async () => {
79
+ const res = await app.inject({
80
+ method: 'GET',
81
+ url: `/invitations/preview?token=${encodeURIComponent(`missing-${crypto.randomUUID()}`)}`,
82
+ });
83
+
84
+ expect(res.statusCode).toBe(200);
85
+ expect(res.json()).toEqual({status: 'invalid'});
86
+ });
87
+
88
+ test('returns expired metadata for expired invitations', async () => {
89
+ const owner = await signupVerifyLogin(app, 'invite-preview-expired-owner');
90
+ const workspaceId = await createWorkspace(app, owner.token, 'Expired Co');
91
+ const rawToken = await createExpiredInvite({
92
+ workspaceId,
93
+ email: uniqueEmail('invite-preview-expired'),
94
+ invitedByUserId: owner.userId,
95
+ });
96
+
97
+ const res = await app.inject({
98
+ method: 'GET',
99
+ url: `/invitations/preview?token=${encodeURIComponent(rawToken)}`,
100
+ });
101
+
102
+ expect(res.statusCode).toBe(200);
103
+ expect(res.json()).toEqual(
104
+ expect.objectContaining({
105
+ status: 'expired',
106
+ workspace_name: 'Expired Co',
107
+ expires_at: expect.any(String),
108
+ }),
109
+ );
110
+ });
111
+
112
+ test('returns already_used after an invitation is accepted', async () => {
113
+ const owner = await signupVerifyLogin(app, 'invite-preview-used-owner');
114
+ const guest = await signupVerifyLogin(app, 'invite-preview-used-guest');
115
+ const workspaceId = await createWorkspace(app, owner.token, 'Used Inc');
116
+ const invite = await createInvite(app, {
117
+ token: owner.token,
118
+ workspaceId,
119
+ email: guest.email,
120
+ });
121
+ const accept = await app.inject({
122
+ method: 'POST',
123
+ url: '/invitations/accept',
124
+ headers: {authorization: `Bearer ${guest.token}`},
125
+ payload: {token: invite.rawToken},
126
+ });
127
+
128
+ const res = await app.inject({
129
+ method: 'GET',
130
+ url: `/invitations/preview?token=${encodeURIComponent(invite.rawToken)}`,
131
+ });
132
+
133
+ expect(accept.statusCode).toBe(201);
134
+ expect(res.statusCode).toBe(200);
135
+ expect(res.json()).toEqual({
136
+ status: 'already_used',
137
+ workspace_name: 'Used Inc',
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,46 @@
1
+ import {
2
+ previewInvitationQuerySchema,
3
+ previewInvitationResponseSchema,
4
+ } from '@shipfox/api-workspaces-dto';
5
+ import {defineRoute} from '@shipfox/node-fastify';
6
+ import {previewInvitation} from '#core/index.js';
7
+
8
+ export const previewInvitationRoute = defineRoute({
9
+ method: 'GET',
10
+ path: '/preview',
11
+ description: 'Preview an invitation by opaque token (public, no auth).',
12
+ schema: {
13
+ querystring: previewInvitationQuerySchema,
14
+ response: {
15
+ 200: previewInvitationResponseSchema,
16
+ },
17
+ },
18
+ handler: async (request) => {
19
+ const result = await previewInvitation({token: request.query.token});
20
+
21
+ switch (result.status) {
22
+ case 'pending':
23
+ return {
24
+ status: 'pending' as const,
25
+ workspace_id: result.workspaceId,
26
+ workspace_name: result.workspaceName,
27
+ email: result.email,
28
+ invited_by_display: result.invitedByDisplay,
29
+ expires_at: result.expiresAt.toISOString(),
30
+ };
31
+ case 'expired':
32
+ return {
33
+ status: 'expired' as const,
34
+ workspace_name: result.workspaceName,
35
+ expires_at: result.expiresAt.toISOString(),
36
+ };
37
+ case 'already_used':
38
+ return {
39
+ status: 'already_used' as const,
40
+ workspace_name: result.workspaceName,
41
+ };
42
+ case 'invalid':
43
+ return {status: 'invalid' as const};
44
+ }
45
+ },
46
+ });
@@ -0,0 +1,99 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {
3
+ createInvite,
4
+ createWorkspace,
5
+ createWorkspacesTestApp,
6
+ resetCapturedMail,
7
+ signupVerifyLogin,
8
+ uniqueEmail,
9
+ } from '#test/routes.js';
10
+
11
+ describe('DELETE /workspaces/:workspaceId/invitations/:invitationId', () => {
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 204 and removes an open invitation', async () => {
27
+ const owner = await signupVerifyLogin(app, 'invite-revoke-owner');
28
+ const workspaceId = await createWorkspace(app, owner.token);
29
+ const invite = await createInvite(app, {
30
+ token: owner.token,
31
+ workspaceId,
32
+ email: uniqueEmail('invite-revoke'),
33
+ });
34
+
35
+ const revoke = await app.inject({
36
+ method: 'DELETE',
37
+ url: `/workspaces/${workspaceId}/invitations/${invite.id}`,
38
+ headers: {authorization: `Bearer ${owner.token}`},
39
+ });
40
+ const list = await app.inject({
41
+ method: 'GET',
42
+ url: `/workspaces/${workspaceId}/invitations`,
43
+ headers: {authorization: `Bearer ${owner.token}`},
44
+ });
45
+
46
+ expect(revoke.statusCode).toBe(204);
47
+ expect(list.statusCode).toBe(200);
48
+ expect(list.json().invitations).toHaveLength(0);
49
+ });
50
+
51
+ test('transforms missing invitation into 404', async () => {
52
+ const owner = await signupVerifyLogin(app, 'invite-revoke-missing');
53
+ const workspaceId = await createWorkspace(app, owner.token);
54
+
55
+ const res = await app.inject({
56
+ method: 'DELETE',
57
+ url: `/workspaces/${workspaceId}/invitations/${crypto.randomUUID()}`,
58
+ headers: {authorization: `Bearer ${owner.token}`},
59
+ });
60
+
61
+ expect(res.statusCode).toBe(404);
62
+ expect(res.json().code).toBe('not-found');
63
+ });
64
+
65
+ test('transforms invitation workspace mismatch into 403', async () => {
66
+ const owner = await signupVerifyLogin(app, 'invite-revoke-mismatch');
67
+ const firstWorkspaceId = await createWorkspace(app, owner.token);
68
+ const secondWorkspaceId = await createWorkspace(app, owner.token);
69
+ const invite = await createInvite(app, {
70
+ token: owner.token,
71
+ workspaceId: firstWorkspaceId,
72
+ email: uniqueEmail('mismatch-invitee'),
73
+ });
74
+
75
+ const res = await app.inject({
76
+ method: 'DELETE',
77
+ url: `/workspaces/${secondWorkspaceId}/invitations/${invite.id}`,
78
+ headers: {authorization: `Bearer ${owner.token}`},
79
+ });
80
+
81
+ expect(res.statusCode).toBe(403);
82
+ expect(res.json().code).toBe('forbidden');
83
+ });
84
+
85
+ test('transforms missing membership into 403', async () => {
86
+ const outsider = await signupVerifyLogin(app, 'invite-revoke-outsider');
87
+ const workspaceId = crypto.randomUUID();
88
+ const invitationId = crypto.randomUUID();
89
+
90
+ const res = await app.inject({
91
+ method: 'DELETE',
92
+ url: `/workspaces/${workspaceId}/invitations/${invitationId}`,
93
+ headers: {authorization: `Bearer ${outsider.token}`},
94
+ });
95
+
96
+ expect(res.statusCode).toBe(403);
97
+ expect(res.json().code).toBe('forbidden');
98
+ });
99
+ });
@@ -0,0 +1,64 @@
1
+ import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
2
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
3
+ import {z} from 'zod';
4
+ import {
5
+ InvitationNotFoundError,
6
+ InvitationWorkspaceMismatchError,
7
+ MembershipRequiredError,
8
+ WorkspaceInactiveError,
9
+ WorkspaceNotFoundError,
10
+ } from '#core/errors.js';
11
+ import {revokeWorkspaceInvitation} from '#core/index.js';
12
+
13
+ export const revokeInvitationRoute = defineRoute({
14
+ method: 'DELETE',
15
+ path: '/:invitationId',
16
+ description: 'Cancel a pending invitation.',
17
+ auth: AUTH_USER,
18
+ schema: {
19
+ params: z.object({
20
+ workspaceId: z.string().uuid(),
21
+ invitationId: z.string().uuid(),
22
+ }),
23
+ response: {
24
+ 204: z.void(),
25
+ },
26
+ },
27
+ errorHandler: (error) => {
28
+ if (error instanceof WorkspaceNotFoundError) {
29
+ throw new ClientError('Workspace not found', 'not-found', {status: 404});
30
+ }
31
+ if (error instanceof MembershipRequiredError) {
32
+ throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});
33
+ }
34
+ if (error instanceof InvitationNotFoundError) {
35
+ throw new ClientError('Invitation not found', 'not-found', {status: 404});
36
+ }
37
+ if (error instanceof InvitationWorkspaceMismatchError) {
38
+ throw new ClientError('Invitation does not belong to this workspace', 'forbidden', {
39
+ status: 403,
40
+ });
41
+ }
42
+ if (error instanceof WorkspaceInactiveError) {
43
+ throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});
44
+ }
45
+ throw error;
46
+ },
47
+ handler: async (request, reply) => {
48
+ const client = getUserContext(request);
49
+ if (!client) {
50
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
51
+ }
52
+
53
+ const {workspaceId, invitationId} = request.params;
54
+
55
+ await revokeWorkspaceInvitation({
56
+ workspaceId,
57
+ invitationId,
58
+ requesterUserId: client.userId,
59
+ requesterMemberships: client.memberships,
60
+ });
61
+
62
+ reply.code(204);
63
+ },
64
+ });
@@ -0,0 +1,5 @@
1
+ import type {RouteDefinition} from '@shipfox/node-fastify';
2
+ import {listMembersRoute} from './list.js';
3
+ import {removeMemberRoute} from './remove.js';
4
+
5
+ export const memberRoutes: RouteDefinition[] = [listMembersRoute, removeMemberRoute];
@@ -0,0 +1,95 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {createWorkspace as createWorkspaceRow} from '#db/workspaces.js';
3
+ import {
4
+ createInvite,
5
+ createWorkspace,
6
+ createWorkspacesTestApp,
7
+ resetCapturedMail,
8
+ signupVerifyLogin,
9
+ } from '#test/routes.js';
10
+
11
+ describe('GET /workspaces/:workspaceId/members', () => {
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 members for a workspace member', async () => {
27
+ const owner = await signupVerifyLogin(app, 'members-list-owner');
28
+ const guest = await signupVerifyLogin(app, 'members-list-guest');
29
+ const workspaceId = await createWorkspace(app, owner.token);
30
+ const invite = await createInvite(app, {token: owner.token, workspaceId, email: guest.email});
31
+ await app.inject({
32
+ method: 'POST',
33
+ url: '/invitations/accept',
34
+ headers: {authorization: `Bearer ${guest.token}`},
35
+ payload: {token: invite.rawToken},
36
+ });
37
+
38
+ const res = await app.inject({
39
+ method: 'GET',
40
+ url: `/workspaces/${workspaceId}/members`,
41
+ headers: {authorization: `Bearer ${owner.token}`},
42
+ });
43
+
44
+ expect(res.statusCode).toBe(200);
45
+ expect(
46
+ res
47
+ .json()
48
+ .members.map((member: {user_email: string}) => member.user_email)
49
+ .sort(),
50
+ ).toEqual([guest.email, owner.email].sort());
51
+ });
52
+
53
+ test('authorizes from a static membership claim without a membership row', async () => {
54
+ const userId = crypto.randomUUID();
55
+ const email = `members-list-claim-${crypto.randomUUID()}@example.com`;
56
+ const workspace = await createWorkspaceRow({name: `Workspace ${crypto.randomUUID()}`});
57
+ const token = `claim:${userId}:${email}:${workspace.id}`;
58
+
59
+ const res = await app.inject({
60
+ method: 'GET',
61
+ url: `/workspaces/${workspace.id}/members`,
62
+ headers: {authorization: `Bearer ${token}`},
63
+ });
64
+
65
+ expect(res.statusCode).toBe(200);
66
+ expect(res.json().members).toEqual([]);
67
+ });
68
+
69
+ test('transforms missing membership into 403', async () => {
70
+ const outsider = await signupVerifyLogin(app, 'members-list-outsider');
71
+ const workspaceId = crypto.randomUUID();
72
+
73
+ const res = await app.inject({
74
+ method: 'GET',
75
+ url: `/workspaces/${workspaceId}/members`,
76
+ headers: {authorization: `Bearer ${outsider.token}`},
77
+ });
78
+
79
+ expect(res.statusCode).toBe(403);
80
+ expect(res.json().code).toBe('forbidden');
81
+ });
82
+
83
+ test('returns 403 when caller has no claim to the workspace (whether or not it exists)', async () => {
84
+ const owner = await signupVerifyLogin(app, 'members-list-missing-workspace');
85
+
86
+ const res = await app.inject({
87
+ method: 'GET',
88
+ url: `/workspaces/${crypto.randomUUID()}/members`,
89
+ headers: {authorization: `Bearer ${owner.token}`},
90
+ });
91
+
92
+ expect(res.statusCode).toBe(403);
93
+ expect(res.json().code).toBe('forbidden');
94
+ });
95
+ });