@shipfox/api-workspaces 10.1.0 → 11.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 (98) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +7 -1
  4. package/dist/core/admin-workspaces.d.ts +16 -0
  5. package/dist/core/admin-workspaces.d.ts.map +1 -1
  6. package/dist/core/admin-workspaces.js +52 -0
  7. package/dist/core/admin-workspaces.js.map +1 -1
  8. package/dist/core/errors.d.ts +12 -0
  9. package/dist/core/errors.d.ts.map +1 -1
  10. package/dist/core/errors.js +24 -0
  11. package/dist/core/errors.js.map +1 -1
  12. package/dist/core/index.d.ts +2 -2
  13. package/dist/core/index.d.ts.map +1 -1
  14. package/dist/core/index.js +2 -2
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/core/invitations.d.ts.map +1 -1
  17. package/dist/core/invitations.js +6 -3
  18. package/dist/core/invitations.js.map +1 -1
  19. package/dist/core/workspaces.d.ts +1 -0
  20. package/dist/core/workspaces.d.ts.map +1 -1
  21. package/dist/core/workspaces.js +5 -3
  22. package/dist/core/workspaces.js.map +1 -1
  23. package/dist/db/admin-workspace-commands.d.ts +17 -0
  24. package/dist/db/admin-workspace-commands.d.ts.map +1 -0
  25. package/dist/db/admin-workspace-commands.js +86 -0
  26. package/dist/db/admin-workspace-commands.js.map +1 -0
  27. package/dist/db/db.d.ts +256 -0
  28. package/dist/db/db.d.ts.map +1 -1
  29. package/dist/db/db.js +2 -0
  30. package/dist/db/db.js.map +1 -1
  31. package/dist/db/index.d.ts +1 -0
  32. package/dist/db/index.d.ts.map +1 -1
  33. package/dist/db/index.js +1 -0
  34. package/dist/db/index.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +135 -0
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  37. package/dist/db/schema/admin-command-results.js +18 -0
  38. package/dist/db/schema/admin-command-results.js.map +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +7 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/presentation/inter-module.d.ts.map +1 -1
  43. package/dist/presentation/inter-module.js +3 -2
  44. package/dist/presentation/inter-module.js.map +1 -1
  45. package/dist/presentation/routes/admin-workspaces.d.ts.map +1 -1
  46. package/dist/presentation/routes/admin-workspaces.js +119 -14
  47. package/dist/presentation/routes/admin-workspaces.js.map +1 -1
  48. package/dist/presentation/routes/invitations/create.d.ts.map +1 -1
  49. package/dist/presentation/routes/invitations/create.js +5 -1
  50. package/dist/presentation/routes/invitations/create.js.map +1 -1
  51. package/dist/presentation/routes/invitations/list.d.ts.map +1 -1
  52. package/dist/presentation/routes/invitations/list.js +5 -1
  53. package/dist/presentation/routes/invitations/list.js.map +1 -1
  54. package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -1
  55. package/dist/presentation/routes/invitations/revoke.js +5 -1
  56. package/dist/presentation/routes/invitations/revoke.js.map +1 -1
  57. package/dist/presentation/routes/members/list.d.ts.map +1 -1
  58. package/dist/presentation/routes/members/list.js +5 -1
  59. package/dist/presentation/routes/members/list.js.map +1 -1
  60. package/dist/presentation/routes/members/remove.d.ts.map +1 -1
  61. package/dist/presentation/routes/members/remove.js +5 -1
  62. package/dist/presentation/routes/members/remove.js.map +1 -1
  63. package/dist/tsconfig.test.tsbuildinfo +1 -1
  64. package/drizzle/0002_wide_enchantress.sql +11 -0
  65. package/drizzle/meta/0002_snapshot.json +555 -0
  66. package/drizzle/meta/_journal.json +7 -0
  67. package/package.json +6 -6
  68. package/src/core/admin-workspaces.ts +102 -0
  69. package/src/core/errors.ts +28 -0
  70. package/src/core/index.ts +8 -0
  71. package/src/core/invitations.test.ts +8 -8
  72. package/src/core/invitations.ts +3 -0
  73. package/src/core/workspaces.test.ts +22 -3
  74. package/src/core/workspaces.ts +4 -1
  75. package/src/db/admin-workspace-commands.ts +169 -0
  76. package/src/db/db.ts +2 -0
  77. package/src/db/index.ts +1 -0
  78. package/src/db/schema/admin-command-results.ts +30 -0
  79. package/src/index.test.ts +3 -1
  80. package/src/index.ts +17 -2
  81. package/src/presentation/inter-module.test.ts +51 -0
  82. package/src/presentation/inter-module.ts +7 -4
  83. package/src/presentation/routes/admin-workspaces.test.ts +273 -3
  84. package/src/presentation/routes/admin-workspaces.ts +143 -13
  85. package/src/presentation/routes/invitations/create.test.ts +15 -0
  86. package/src/presentation/routes/invitations/create.ts +2 -1
  87. package/src/presentation/routes/invitations/list.test.ts +30 -0
  88. package/src/presentation/routes/invitations/list.ts +2 -1
  89. package/src/presentation/routes/invitations/revoke.test.ts +14 -0
  90. package/src/presentation/routes/invitations/revoke.ts +2 -1
  91. package/src/presentation/routes/members/list.test.ts +29 -1
  92. package/src/presentation/routes/members/list.ts +2 -1
  93. package/src/presentation/routes/members/remove.test.ts +14 -0
  94. package/src/presentation/routes/members/remove.ts +2 -1
  95. package/test/globalSetup.ts +3 -1
  96. package/test/routes.ts +8 -3
  97. package/tsconfig.build.tsbuildinfo +1 -1
  98. package/vitest.config.ts +3 -0
@@ -1,9 +1,111 @@
1
+ import {type AdministrationRole, createAdministrationActionEvent} from '@shipfox/api-common-dto';
1
2
  import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
2
3
  import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
3
4
  import type {StringIdCursor} from '@shipfox/node-drizzle';
4
5
  import {logger} from '@shipfox/node-opentelemetry';
6
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
7
+ import {
8
+ reactivateWorkspaceWithAudit,
9
+ suspendWorkspaceWithAudit,
10
+ } from '#db/admin-workspace-commands.js';
5
11
  import {listAdminWorkspaces as listAdminWorkspaceRows} from '#db/workspaces.js';
6
12
 
13
+ const ADMIN_OPERATOR_ROLE: AdministrationRole = 'admin-operator';
14
+ const SUSPEND_COMMAND = 'workspace.suspend';
15
+ const REACTIVATE_COMMAND = 'workspace.reactivate';
16
+
17
+ export interface WorkspaceAdministrationMutationContext {
18
+ actorId: string;
19
+ actorRole: AdministrationRole;
20
+ idempotencyKey: string;
21
+ correlationId: string;
22
+ workspaceId: string;
23
+ reason: string;
24
+ }
25
+
26
+ export interface WorkspaceAdministrationMutationResult {
27
+ workspaceId: string;
28
+ status: 'active' | 'suspended';
29
+ correlationId: string;
30
+ }
31
+
32
+ function commandFingerprint(
33
+ command: string,
34
+ params: WorkspaceAdministrationMutationContext,
35
+ ): string {
36
+ return hashOpaqueToken(
37
+ `${command}:${JSON.stringify({workspaceId: params.workspaceId, reason: params.reason})}`,
38
+ );
39
+ }
40
+
41
+ function administrationEvent(params: {
42
+ actorId: string;
43
+ actorRole: AdministrationRole;
44
+ command: string;
45
+ workspaceId: string;
46
+ reason: string;
47
+ correlationId: string;
48
+ idempotencyKeyFingerprint: string;
49
+ }) {
50
+ return createAdministrationActionEvent({
51
+ actorId: params.actorId,
52
+ actorRole: params.actorRole,
53
+ requiredRole: ADMIN_OPERATOR_ROLE,
54
+ command: params.command,
55
+ targetType: 'workspace',
56
+ targetId: params.workspaceId,
57
+ reason: params.reason,
58
+ result: 'succeeded',
59
+ correlationId: params.correlationId,
60
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
61
+ occurredAt: new Date().toISOString(),
62
+ });
63
+ }
64
+
65
+ async function runWorkspaceAdministrationMutation(
66
+ params: WorkspaceAdministrationMutationContext,
67
+ command: string,
68
+ update: typeof suspendWorkspaceWithAudit,
69
+ ): Promise<WorkspaceAdministrationMutationResult> {
70
+ const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
71
+ const result = await update({
72
+ actorId: params.actorId,
73
+ workspaceId: params.workspaceId,
74
+ idempotencyKeyFingerprint,
75
+ requestFingerprint: commandFingerprint(command, params),
76
+ event: administrationEvent({
77
+ actorId: params.actorId,
78
+ actorRole: params.actorRole,
79
+ command,
80
+ workspaceId: params.workspaceId,
81
+ reason: params.reason,
82
+ correlationId: params.correlationId,
83
+ idempotencyKeyFingerprint,
84
+ }),
85
+ });
86
+ return result;
87
+ }
88
+
89
+ export async function suspendWorkspace(
90
+ params: WorkspaceAdministrationMutationContext,
91
+ ): Promise<WorkspaceAdministrationMutationResult> {
92
+ return await runWorkspaceAdministrationMutation(
93
+ params,
94
+ SUSPEND_COMMAND,
95
+ suspendWorkspaceWithAudit,
96
+ );
97
+ }
98
+
99
+ export async function reactivateWorkspace(
100
+ params: WorkspaceAdministrationMutationContext,
101
+ ): Promise<WorkspaceAdministrationMutationResult> {
102
+ return await runWorkspaceAdministrationMutation(
103
+ params,
104
+ REACTIVATE_COMMAND,
105
+ reactivateWorkspaceWithAudit,
106
+ );
107
+ }
108
+
7
109
  export interface WorkspaceAdministratorSummary {
8
110
  id: string;
9
111
  name: string;
@@ -12,6 +12,34 @@ export class WorkspaceInactiveError extends Error {
12
12
  }
13
13
  }
14
14
 
15
+ export class WorkspaceAlreadySuspendedError extends Error {
16
+ constructor(workspaceId: string) {
17
+ super(`Workspace is already suspended: ${workspaceId}`);
18
+ this.name = 'WorkspaceAlreadySuspendedError';
19
+ }
20
+ }
21
+
22
+ export class WorkspaceNotSuspendedError extends Error {
23
+ constructor(workspaceId: string) {
24
+ super(`Workspace is not suspended: ${workspaceId}`);
25
+ this.name = 'WorkspaceNotSuspendedError';
26
+ }
27
+ }
28
+
29
+ export class WorkspaceDeletedError extends Error {
30
+ constructor(workspaceId: string) {
31
+ super(`Workspace is deleted: ${workspaceId}`);
32
+ this.name = 'WorkspaceDeletedError';
33
+ }
34
+ }
35
+
36
+ export class WorkspaceAdminIdempotencyKeyReuseError extends Error {
37
+ constructor() {
38
+ super('Idempotency key was already used for a different workspace command');
39
+ this.name = 'WorkspaceAdminIdempotencyKeyReuseError';
40
+ }
41
+ }
42
+
15
43
  export class InvitationNotFoundError extends Error {
16
44
  constructor(id: string) {
17
45
  super(`Invitation not found: ${id}`);
package/src/core/index.ts CHANGED
@@ -2,6 +2,10 @@ export {
2
2
  type ListWorkspaceAdministratorSummariesParams,
3
3
  type ListWorkspaceAdministratorSummariesResult,
4
4
  listWorkspaceAdministratorSummaries,
5
+ reactivateWorkspace,
6
+ suspendWorkspace,
7
+ type WorkspaceAdministrationMutationContext,
8
+ type WorkspaceAdministrationMutationResult,
5
9
  type WorkspaceAdministratorSummary,
6
10
  } from './admin-workspaces.js';
7
11
  export type {Invitation} from './entities/invitation.js';
@@ -18,7 +22,11 @@ export {
18
22
  SelfRemovalNotAllowedError,
19
23
  TokenAlreadyUsedError,
20
24
  TokenExpiredError,
25
+ WorkspaceAdminIdempotencyKeyReuseError,
26
+ WorkspaceAlreadySuspendedError,
27
+ WorkspaceDeletedError,
21
28
  WorkspaceNotFoundError,
29
+ WorkspaceNotSuspendedError,
22
30
  } from './errors.js';
23
31
  export {
24
32
  acceptWorkspaceInvitation,
@@ -105,7 +105,7 @@ describe('invitations core', () => {
105
105
  workspaceId: workspace.id,
106
106
  email,
107
107
  invitedByUserId: inviter.userId,
108
- invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
108
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
109
109
  });
110
110
 
111
111
  expect(invitation.email).toBe(email);
@@ -129,7 +129,7 @@ describe('invitations core', () => {
129
129
  email,
130
130
  invitedByUserId: inviter.userId,
131
131
  invitedByDisplay: 'Dana Scully',
132
- invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
132
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
133
133
  });
134
134
 
135
135
  const payload = await latestInvitationPayload(email);
@@ -157,7 +157,7 @@ describe('invitations core', () => {
157
157
  workspaceId: workspace.id,
158
158
  email,
159
159
  invitedByUserId: inviter.userId,
160
- invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
160
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
161
161
  });
162
162
 
163
163
  const payload = await latestInvitationPayload(email);
@@ -181,7 +181,7 @@ describe('invitations core', () => {
181
181
  email,
182
182
  invitedByUserId: inviter.userId,
183
183
  invitedByDisplay: ' ',
184
- invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
184
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
185
185
  });
186
186
 
187
187
  const payload = await latestInvitationPayload(email);
@@ -204,14 +204,14 @@ describe('invitations core', () => {
204
204
  workspaceId: workspace.id,
205
205
  email,
206
206
  invitedByUserId: inviter.userId,
207
- invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
207
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
208
208
  });
209
209
 
210
210
  const promise = createWorkspaceInvitation({
211
211
  workspaceId: workspace.id,
212
212
  email,
213
213
  invitedByUserId: inviter.userId,
214
- invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
214
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
215
215
  });
216
216
 
217
217
  await expect(promise).rejects.toBeInstanceOf(OpenInvitationExistsError);
@@ -233,7 +233,7 @@ describe('invitations core', () => {
233
233
  workspaceId: workspace.id,
234
234
  email: invitee.email,
235
235
  invitedByUserId: inviter.userId,
236
- invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
236
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
237
237
  });
238
238
  const token = extractToken((await latestInvitationPayload(invitee.email)).inviteLink);
239
239
 
@@ -269,7 +269,7 @@ describe('invitations core', () => {
269
269
  workspaceId: workspace.id,
270
270
  email,
271
271
  invitedByUserId: inviter.userId,
272
- invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
272
+ invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
273
273
  });
274
274
  const payload = await latestInvitationPayload(email);
275
275
  const token = extractToken(payload.inviteLink);
@@ -46,6 +46,7 @@ export async function createWorkspaceInvitation(params: {
46
46
  workspaceId: params.workspaceId,
47
47
  userId: params.invitedByUserId,
48
48
  memberships: params.invitedByMemberships,
49
+ enforceWorkspaceStatus: false,
49
50
  });
50
51
 
51
52
  const rawToken = generateOpaqueToken('invitation');
@@ -216,6 +217,7 @@ export async function listWorkspaceInvitations(params: {
216
217
  workspaceId: params.workspaceId,
217
218
  userId: params.requesterUserId,
218
219
  memberships: params.requesterMemberships,
220
+ enforceWorkspaceStatus: false,
219
221
  });
220
222
 
221
223
  return listOpenInvitationsByWorkspace({workspaceId: params.workspaceId});
@@ -231,6 +233,7 @@ export async function revokeWorkspaceInvitation(params: {
231
233
  workspaceId: params.workspaceId,
232
234
  userId: params.requesterUserId,
233
235
  memberships: params.requesterMemberships,
236
+ enforceWorkspaceStatus: false,
234
237
  });
235
238
 
236
239
  const invitation = await findInvitationById({id: params.invitationId});
@@ -5,7 +5,7 @@ import {findMembership} from '#db/memberships.js';
5
5
  import {workspacesOutbox} from '#db/schema/outbox.js';
6
6
  import {updateWorkspace} from '#db/workspaces.js';
7
7
  import {userFactory} from '#test/index.js';
8
- import {MembershipRequiredError, WorkspaceNotFoundError} from './errors.js';
8
+ import {MembershipRequiredError, WorkspaceInactiveError, WorkspaceNotFoundError} from './errors.js';
9
9
  import {
10
10
  createWorkspaceForUser,
11
11
  getWorkspaceOperatingState,
@@ -64,7 +64,7 @@ describe('workspaces core', () => {
64
64
  const result = await requireWorkspaceMembership({
65
65
  workspaceId: workspace.id,
66
66
  userId: user.userId,
67
- memberships: [{workspaceId: workspace.id, role: 'admin'}],
67
+ memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
68
68
  });
69
69
 
70
70
  expect(result.workspace.id).toBe(workspace.id);
@@ -85,6 +85,25 @@ describe('workspaces core', () => {
85
85
  expect(await getWorkspaceOperatingState({workspaceId: workspace.id})).toBe('suspended');
86
86
  });
87
87
 
88
+ test('requireWorkspaceMembership rejects a suspended workspace', async () => {
89
+ const user = userFactory.build();
90
+ const workspace = await createWorkspaceForUser({
91
+ name: 'Suspended Workspace',
92
+ userId: user.userId,
93
+ userEmail: user.email,
94
+ userName: user.name,
95
+ });
96
+ await updateWorkspace({id: workspace.id, status: 'suspended'});
97
+
98
+ const membership = requireWorkspaceMembership({
99
+ workspaceId: workspace.id,
100
+ userId: user.userId,
101
+ memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
102
+ });
103
+
104
+ await expect(membership).rejects.toBeInstanceOf(WorkspaceInactiveError);
105
+ });
106
+
88
107
  test('requireWorkspaceMembership rejects when memberships do not include the workspace', async () => {
89
108
  const owner = userFactory.build();
90
109
  const outsider = userFactory.build();
@@ -110,7 +129,7 @@ describe('workspaces core', () => {
110
129
  const missingWorkspace = requireWorkspaceMembership({
111
130
  workspaceId: ghostId,
112
131
  userId: owner.userId,
113
- memberships: [{workspaceId: ghostId, role: 'admin'}],
132
+ memberships: [{workspaceId: ghostId, role: 'admin', workspaceStatus: 'active'}],
114
133
  });
115
134
 
116
135
  await expect(missingWorkspace).rejects.toBeInstanceOf(WorkspaceNotFoundError);
@@ -31,6 +31,7 @@ export interface RequireWorkspaceMembershipParams {
31
31
  workspaceId: string;
32
32
  userId: string;
33
33
  memberships: ReadonlyArray<UserContextMembership>;
34
+ enforceWorkspaceStatus?: boolean;
34
35
  }
35
36
 
36
37
  export interface RequireWorkspaceMembershipResult {
@@ -52,7 +53,7 @@ export async function requireWorkspaceMembership(
52
53
  if (!workspace) {
53
54
  throw new WorkspaceNotFoundError(params.workspaceId);
54
55
  }
55
- if (workspace.status !== 'active') {
56
+ if (params.enforceWorkspaceStatus !== false && workspace.status !== 'active') {
56
57
  throw new WorkspaceInactiveError(params.workspaceId);
57
58
  }
58
59
 
@@ -128,6 +129,7 @@ export async function listWorkspaceMembers(params: {
128
129
  workspaceId: params.workspaceId,
129
130
  userId: params.requesterUserId,
130
131
  memberships: params.requesterMemberships,
132
+ enforceWorkspaceStatus: false,
131
133
  });
132
134
 
133
135
  return listMembershipsByWorkspace({workspaceId: params.workspaceId});
@@ -143,6 +145,7 @@ export async function removeWorkspaceMember(params: {
143
145
  workspaceId: params.workspaceId,
144
146
  userId: params.requesterUserId,
145
147
  memberships: params.requesterMemberships,
148
+ enforceWorkspaceStatus: false,
146
149
  });
147
150
 
148
151
  if (params.userId === params.requesterUserId) {
@@ -0,0 +1,169 @@
1
+ import type {
2
+ AdministrationActionEvent,
3
+ AdministrationActionEventMap,
4
+ } from '@shipfox/api-common-dto';
5
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
6
+ import {and, eq, sql} from 'drizzle-orm';
7
+ import {
8
+ WorkspaceAdminIdempotencyKeyReuseError,
9
+ WorkspaceAlreadySuspendedError,
10
+ WorkspaceDeletedError,
11
+ WorkspaceNotFoundError,
12
+ WorkspaceNotSuspendedError,
13
+ } from '#core/errors.js';
14
+ import {db} from './db.js';
15
+ import {
16
+ type WorkspaceAdminCommandResultDb,
17
+ workspacesAdminCommandResults,
18
+ } from './schema/admin-command-results.js';
19
+ import {workspacesOutbox} from './schema/outbox.js';
20
+ import {workspaces} from './schema/workspaces.js';
21
+
22
+ type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
23
+
24
+ export interface WorkspaceAdministrationResult {
25
+ workspaceId: string;
26
+ status: 'active' | 'suspended';
27
+ correlationId: string;
28
+ }
29
+
30
+ interface AuditedWorkspaceCommandParams {
31
+ actorId: string;
32
+ idempotencyKeyFingerprint: string;
33
+ requestFingerprint: string;
34
+ event: AdministrationActionEvent;
35
+ workspaceId: string;
36
+ }
37
+
38
+ async function loadCommandResult(
39
+ tx: Tx,
40
+ params: Pick<AuditedWorkspaceCommandParams, 'actorId' | 'idempotencyKeyFingerprint'> & {
41
+ command: string;
42
+ requestFingerprint: string;
43
+ },
44
+ ): Promise<WorkspaceAdministrationResult | undefined> {
45
+ const rows = await tx
46
+ .select()
47
+ .from(workspacesAdminCommandResults)
48
+ .where(
49
+ and(
50
+ eq(workspacesAdminCommandResults.actorId, params.actorId),
51
+ eq(
52
+ workspacesAdminCommandResults.idempotencyKeyFingerprint,
53
+ params.idempotencyKeyFingerprint,
54
+ ),
55
+ ),
56
+ )
57
+ .limit(1);
58
+ const result: WorkspaceAdminCommandResultDb | undefined = rows[0];
59
+ if (!result) return undefined;
60
+ if (
61
+ result.command !== params.command ||
62
+ result.requestFingerprint !== params.requestFingerprint
63
+ ) {
64
+ throw new WorkspaceAdminIdempotencyKeyReuseError();
65
+ }
66
+ return result.result;
67
+ }
68
+
69
+ async function storeCommandResult(
70
+ tx: Tx,
71
+ params: AuditedWorkspaceCommandParams,
72
+ result: WorkspaceAdministrationResult,
73
+ ): Promise<void> {
74
+ await tx.insert(workspacesAdminCommandResults).values({
75
+ actorId: params.actorId,
76
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
77
+ command: params.event.command,
78
+ requestFingerprint: params.requestFingerprint,
79
+ result,
80
+ });
81
+ }
82
+
83
+ async function writeAdministrationAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
84
+ await writeOutboxEvent<AdministrationActionEventMap>(tx, workspacesOutbox, {
85
+ type: 'administration.action.performed',
86
+ payload: event,
87
+ });
88
+ }
89
+
90
+ async function lockAdminCommand(
91
+ tx: Tx,
92
+ params: {actorId: string; idempotencyKeyFingerprint: string},
93
+ ): Promise<void> {
94
+ await tx.execute(
95
+ sql`select pg_advisory_xact_lock(hashtext(${`workspaces_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
96
+ );
97
+ }
98
+
99
+ async function updateWorkspaceStatusWithAudit(
100
+ params: AuditedWorkspaceCommandParams & {
101
+ expectedStatus: 'active' | 'suspended';
102
+ nextStatus: 'active' | 'suspended';
103
+ },
104
+ ): Promise<WorkspaceAdministrationResult> {
105
+ return await db().transaction(async (tx) => {
106
+ await lockAdminCommand(tx, params);
107
+
108
+ const existing = await loadCommandResult(tx, {
109
+ actorId: params.actorId,
110
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
111
+ command: params.event.command,
112
+ requestFingerprint: params.requestFingerprint,
113
+ });
114
+ if (existing) return existing;
115
+
116
+ const rows = await tx
117
+ .select()
118
+ .from(workspaces)
119
+ .where(eq(workspaces.id, params.workspaceId))
120
+ .for('update')
121
+ .limit(1);
122
+ const workspaceRow = rows[0];
123
+ if (!workspaceRow) throw new WorkspaceNotFoundError(params.workspaceId);
124
+ if (workspaceRow.status === 'deleted') throw new WorkspaceDeletedError(params.workspaceId);
125
+ if (workspaceRow.status !== params.expectedStatus) {
126
+ if (params.nextStatus === 'suspended') {
127
+ throw new WorkspaceAlreadySuspendedError(params.workspaceId);
128
+ }
129
+ throw new WorkspaceNotSuspendedError(params.workspaceId);
130
+ }
131
+
132
+ const updatedRows = await tx
133
+ .update(workspaces)
134
+ .set({status: params.nextStatus, updatedAt: new Date()})
135
+ .where(eq(workspaces.id, params.workspaceId))
136
+ .returning();
137
+ const updatedRow = updatedRows[0];
138
+ if (!updatedRow) throw new WorkspaceNotFoundError(params.workspaceId);
139
+
140
+ const result = {
141
+ workspaceId: updatedRow.id,
142
+ status: updatedRow.status as 'active' | 'suspended',
143
+ correlationId: params.event.correlationId,
144
+ } satisfies WorkspaceAdministrationResult;
145
+ await writeAdministrationAction(tx, params.event);
146
+ await storeCommandResult(tx, params, result);
147
+ return result;
148
+ });
149
+ }
150
+
151
+ export async function suspendWorkspaceWithAudit(
152
+ params: AuditedWorkspaceCommandParams,
153
+ ): Promise<WorkspaceAdministrationResult> {
154
+ return await updateWorkspaceStatusWithAudit({
155
+ ...params,
156
+ expectedStatus: 'active',
157
+ nextStatus: 'suspended',
158
+ });
159
+ }
160
+
161
+ export async function reactivateWorkspaceWithAudit(
162
+ params: AuditedWorkspaceCommandParams,
163
+ ): Promise<WorkspaceAdministrationResult> {
164
+ return await updateWorkspaceStatusWithAudit({
165
+ ...params,
166
+ expectedStatus: 'suspended',
167
+ nextStatus: 'active',
168
+ });
169
+ }
package/src/db/db.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
2
  import {pgClient} from '@shipfox/node-postgres';
3
+ import {workspacesAdminCommandResults} from './schema/admin-command-results.js';
3
4
  import {invitations} from './schema/invitations.js';
4
5
  import {memberships} from './schema/memberships.js';
5
6
  import {workspacesOutbox} from './schema/outbox.js';
6
7
  import {workspaces} from './schema/workspaces.js';
7
8
 
8
9
  export const schema = {
10
+ workspacesAdminCommandResults,
9
11
  workspaces,
10
12
  memberships,
11
13
  invitations,
package/src/db/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  listMembershipsByWorkspace,
23
23
  removeMembership,
24
24
  } from './memberships.js';
25
+ export {workspacesAdminCommandResults} from './schema/admin-command-results.js';
25
26
  export {workspacesOutbox} from './schema/outbox.js';
26
27
  export type {
27
28
  AdminWorkspaceRow,
@@ -0,0 +1,30 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
+ import {pgTable} from './common.js';
4
+
5
+ export interface StoredWorkspaceAdministrationResult {
6
+ workspaceId: string;
7
+ status: 'active' | 'suspended';
8
+ correlationId: string;
9
+ }
10
+
11
+ export const workspacesAdminCommandResults = pgTable(
12
+ 'admin_command_results',
13
+ {
14
+ id: uuidv7PrimaryKey(),
15
+ actorId: uuid('actor_id').notNull(),
16
+ idempotencyKeyFingerprint: text('idempotency_key_fingerprint').notNull(),
17
+ command: text('command').notNull(),
18
+ requestFingerprint: text('request_fingerprint').notNull(),
19
+ result: jsonb('result').$type<StoredWorkspaceAdministrationResult>().notNull(),
20
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
21
+ },
22
+ (table) => [
23
+ uniqueIndex('workspaces_admin_command_results_actor_key_unique').on(
24
+ table.actorId,
25
+ table.idempotencyKeyFingerprint,
26
+ ),
27
+ ],
28
+ );
29
+
30
+ export type WorkspaceAdminCommandResultDb = typeof workspacesAdminCommandResults.$inferSelect;
package/src/index.test.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
1
2
  import {
2
3
  WORKSPACES_INVITATION_SEND_REQUESTED,
3
4
  WORKSPACES_MEMBER_INVITED,
@@ -22,12 +23,13 @@ describe('workspacesModule', () => {
22
23
  const publisher = workspacesModule.publishers?.find((pub) => pub.name === 'workspaces');
23
24
  const events = workspacesModule.subscribers?.map((subscriber) => subscriber.event);
24
25
 
25
- expect(publisher?.eventSchemas).toBe(workspacesEventSchemas);
26
+ expect(publisher?.eventSchemas).not.toBe(workspacesEventSchemas);
26
27
  expect(Object.keys(publisher?.eventSchemas ?? {})).toEqual([
27
28
  WORKSPACES_INVITATION_SEND_REQUESTED,
28
29
  WORKSPACES_WORKSPACE_CREATED,
29
30
  WORKSPACES_MEMBER_INVITED,
30
31
  WORKSPACES_MEMBER_JOINED,
32
+ ADMINISTRATION_ACTION_PERFORMED,
31
33
  ]);
32
34
  expect(events).toContain(WORKSPACES_INVITATION_SEND_REQUESTED);
33
35
  });
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
2
+ import {administrationActionEventSchemas} from '@shipfox/api-common-dto';
2
3
  import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
3
4
  import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
4
5
  import {
@@ -38,6 +39,10 @@ export {listMembershipsByUser} from '#db/memberships.js';
38
39
  export {createWorkspacesRoutes, workspacesRoutes as routes} from '#presentation/routes/index.js';
39
40
 
40
41
  const subscriber = subscriberFactory<WorkspacesEventMap>();
42
+ const workspacesPublisherEventSchemas = {
43
+ ...workspacesEventSchemas,
44
+ ...administrationActionEventSchemas,
45
+ };
41
46
 
42
47
  export interface WorkspacesModuleOptions {
43
48
  auth: AuthInterModuleClient;
@@ -52,7 +57,12 @@ export function createWorkspacesModule(options: WorkspacesModuleOptions): Shipfo
52
57
  routes: createWorkspacesRoutes(options),
53
58
  e2eRoutes: [workspacesE2eRoutes],
54
59
  publishers: [
55
- {name: 'workspaces', table: workspacesOutbox, db, eventSchemas: workspacesEventSchemas},
60
+ {
61
+ name: 'workspaces',
62
+ table: workspacesOutbox,
63
+ db,
64
+ eventSchemas: workspacesPublisherEventSchemas,
65
+ },
56
66
  ],
57
67
  subscribers: [subscriber(WORKSPACES_INVITATION_SEND_REQUESTED, onInvitationSendRequested)],
58
68
  metrics: registerWorkspacesServiceMetrics,
@@ -67,7 +77,12 @@ export const workspacesModule: ShipfoxModule = {
67
77
  routes: workspacesRoutes,
68
78
  e2eRoutes: [workspacesE2eRoutes],
69
79
  publishers: [
70
- {name: 'workspaces', table: workspacesOutbox, db, eventSchemas: workspacesEventSchemas},
80
+ {
81
+ name: 'workspaces',
82
+ table: workspacesOutbox,
83
+ db,
84
+ eventSchemas: workspacesPublisherEventSchemas,
85
+ },
71
86
  ],
72
87
  subscribers: [subscriber(WORKSPACES_INVITATION_SEND_REQUESTED, onInvitationSendRequested)],
73
88
  metrics: registerWorkspacesServiceMetrics,