@shipfox/api-workspaces 9.3.0 → 10.1.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 (64) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +5 -2
  4. package/dist/core/admin-workspaces.d.ts +41 -0
  5. package/dist/core/admin-workspaces.d.ts.map +1 -0
  6. package/dist/core/admin-workspaces.js +86 -0
  7. package/dist/core/admin-workspaces.js.map +1 -0
  8. package/dist/core/index.d.ts +1 -0
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +1 -0
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/db/index.d.ts +2 -2
  13. package/dist/db/index.d.ts.map +1 -1
  14. package/dist/db/index.js +1 -1
  15. package/dist/db/index.js.map +1 -1
  16. package/dist/db/memberships.d.ts +2 -0
  17. package/dist/db/memberships.d.ts.map +1 -1
  18. package/dist/db/memberships.js +4 -2
  19. package/dist/db/memberships.js.map +1 -1
  20. package/dist/db/schema/workspaces.d.ts.map +1 -1
  21. package/dist/db/schema/workspaces.js +4 -2
  22. package/dist/db/schema/workspaces.js.map +1 -1
  23. package/dist/db/workspaces.d.ts +16 -0
  24. package/dist/db/workspaces.d.ts.map +1 -1
  25. package/dist/db/workspaces.js +43 -1
  26. package/dist/db/workspaces.js.map +1 -1
  27. package/dist/index.d.ts +11 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +32 -3
  30. package/dist/index.js.map +1 -1
  31. package/dist/presentation/dto/membership.d.ts.map +1 -1
  32. package/dist/presentation/dto/membership.js +1 -0
  33. package/dist/presentation/dto/membership.js.map +1 -1
  34. package/dist/presentation/index.d.ts +1 -1
  35. package/dist/presentation/index.d.ts.map +1 -1
  36. package/dist/presentation/index.js +1 -1
  37. package/dist/presentation/index.js.map +1 -1
  38. package/dist/presentation/routes/admin-workspaces.d.ts +10 -0
  39. package/dist/presentation/routes/admin-workspaces.d.ts.map +1 -0
  40. package/dist/presentation/routes/admin-workspaces.js +84 -0
  41. package/dist/presentation/routes/admin-workspaces.js.map +1 -0
  42. package/dist/presentation/routes/index.d.ts +8 -0
  43. package/dist/presentation/routes/index.d.ts.map +1 -1
  44. package/dist/presentation/routes/index.js +7 -0
  45. package/dist/presentation/routes/index.js.map +1 -1
  46. package/dist/tsconfig.test.tsbuildinfo +1 -1
  47. package/drizzle/0001_complex_beast.sql +1 -0
  48. package/drizzle/meta/0001_snapshot.json +476 -0
  49. package/drizzle/meta/_journal.json +7 -0
  50. package/package.json +8 -5
  51. package/src/core/admin-workspaces.ts +114 -0
  52. package/src/core/index.ts +6 -0
  53. package/src/db/index.ts +4 -0
  54. package/src/db/memberships.ts +8 -1
  55. package/src/db/schema/workspaces.ts +14 -10
  56. package/src/db/workspaces.test.ts +25 -0
  57. package/src/db/workspaces.ts +75 -1
  58. package/src/index.ts +27 -2
  59. package/src/presentation/dto/membership.ts +1 -0
  60. package/src/presentation/index.ts +1 -1
  61. package/src/presentation/routes/admin-workspaces.test.ts +168 -0
  62. package/src/presentation/routes/admin-workspaces.ts +93 -0
  63. package/src/presentation/routes/index.ts +12 -0
  64. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,5 +1,6 @@
1
1
  import {and, eq, sql} from 'drizzle-orm';
2
2
  import type {Membership} from '#core/entities/membership.js';
3
+ import type {Workspace} from '#core/entities/workspace.js';
3
4
  import {LastMemberError} from '#core/errors.js';
4
5
  import {recordWorkspaceMembershipChanged} from '#metrics/instance.js';
5
6
  import {db} from './db.js';
@@ -58,6 +59,7 @@ export async function ensureMembership(params: EnsureMembershipParams): Promise<
58
59
 
59
60
  export interface MembershipWithWorkspace extends Membership {
60
61
  workspaceName: string;
62
+ workspaceStatus: Workspace['status'];
61
63
  }
62
64
 
63
65
  export async function listMembershipsByUser(params: {
@@ -67,13 +69,18 @@ export async function listMembershipsByUser(params: {
67
69
  .select({
68
70
  membership: memberships,
69
71
  workspaceName: workspaces.name,
72
+ workspaceStatus: workspaces.status,
70
73
  })
71
74
  .from(memberships)
72
75
  .innerJoin(workspaces, eq(workspaces.id, memberships.workspaceId))
73
76
  .where(eq(memberships.userId, params.userId))
74
77
  .orderBy(workspaces.name);
75
78
 
76
- return rows.map((row) => ({...toMembership(row.membership), workspaceName: row.workspaceName}));
79
+ return rows.map((row) => ({
80
+ ...toMembership(row.membership),
81
+ workspaceName: row.workspaceName,
82
+ workspaceStatus: row.workspaceStatus,
83
+ }));
77
84
  }
78
85
 
79
86
  export interface MembershipWithUser extends Membership {
@@ -1,5 +1,5 @@
1
1
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
- import {jsonb, pgEnum, text, timestamp, uuid} from 'drizzle-orm/pg-core';
2
+ import {index, jsonb, pgEnum, text, timestamp, uuid} from 'drizzle-orm/pg-core';
3
3
  import type {Workspace, WorkspaceStatus} from '#core/entities/workspace.js';
4
4
  import {pgTable} from './common.js';
5
5
 
@@ -9,15 +9,19 @@ export const workspaceStatusEnum = pgEnum('workspaces_workspace_status', [
9
9
  'deleted',
10
10
  ]);
11
11
 
12
- export const workspaces = pgTable('workspaces', {
13
- id: uuidv7PrimaryKey(),
14
- name: text('name').notNull(),
15
- status: workspaceStatusEnum('status').notNull().default('active'),
16
- settings: jsonb('settings').notNull().default({}),
17
- createdBy: uuid('created_by'),
18
- createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
19
- updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
20
- });
12
+ export const workspaces = pgTable(
13
+ 'workspaces',
14
+ {
15
+ id: uuidv7PrimaryKey(),
16
+ name: text('name').notNull(),
17
+ status: workspaceStatusEnum('status').notNull().default('active'),
18
+ settings: jsonb('settings').notNull().default({}),
19
+ createdBy: uuid('created_by'),
20
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
21
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
22
+ },
23
+ (table) => [index('workspaces_name_id_idx').on(table.name, table.id)],
24
+ );
21
25
 
22
26
  export type WorkspaceDb = typeof workspaces.$inferSelect;
23
27
  export type WorkspaceCreateDb = typeof workspaces.$inferInsert;
@@ -4,6 +4,7 @@ import {
4
4
  createWorkspace,
5
5
  getWorkspaceById,
6
6
  getWorkspaceServiceMetrics,
7
+ listAdminWorkspaces,
7
8
  updateWorkspace,
8
9
  } from './workspaces.js';
9
10
 
@@ -112,4 +113,28 @@ describe('workspace queries', () => {
112
113
  expect(metrics.openInvitations).toBeGreaterThanOrEqual(1);
113
114
  });
114
115
  });
116
+
117
+ describe('listAdminWorkspaces', () => {
118
+ test('returns bounded, name-ordered pages with member counts', async () => {
119
+ const prefix = `Admin page ${crypto.randomUUID()}`;
120
+ const alpha = await createWorkspace({name: `${prefix} Alpha`});
121
+ const bravo = await createWorkspace({name: `${prefix} Bravo`});
122
+ const charlie = await createWorkspace({name: `${prefix} Charlie`});
123
+ await createMembership({userId: crypto.randomUUID(), workspaceId: bravo.id});
124
+ await createMembership({userId: crypto.randomUUID(), workspaceId: bravo.id});
125
+
126
+ const firstPage = await listAdminWorkspaces({search: prefix, limit: 2});
127
+ const secondPage = await listAdminWorkspaces({
128
+ search: prefix,
129
+ limit: 2,
130
+ cursor: firstPage.nextCursor ?? undefined,
131
+ });
132
+
133
+ expect(firstPage.workspaces.map(({id}) => id)).toEqual([alpha.id, bravo.id]);
134
+ expect(firstPage.workspaces.map(({memberCount}) => memberCount)).toEqual([0, 2]);
135
+ expect(firstPage.nextCursor).toEqual({value: bravo.name, id: bravo.id});
136
+ expect(secondPage.workspaces.map(({id}) => id)).toEqual([charlie.id]);
137
+ expect(secondPage.nextCursor).toBeNull();
138
+ });
139
+ });
115
140
  });
@@ -1,4 +1,5 @@
1
- import {and, count, eq, gt, isNull, sql} from 'drizzle-orm';
1
+ import type {StringIdCursor} from '@shipfox/node-drizzle';
2
+ import {and, asc, count, eq, gt, ilike, inArray, isNull, or, type SQL, sql} from 'drizzle-orm';
2
3
  import type {Workspace, WorkspaceStatus} from '#core/entities/workspace.js';
3
4
  import {recordWorkspaceCreated} from '#metrics/instance.js';
4
5
  import {db} from './db.js';
@@ -57,6 +58,79 @@ export async function getWorkspaceById(id: string): Promise<Workspace | undefine
57
58
  return toWorkspace(row);
58
59
  }
59
60
 
61
+ export interface AdminWorkspaceRow extends Workspace {
62
+ memberCount: number;
63
+ }
64
+
65
+ export interface ListAdminWorkspaceParams {
66
+ workspaceId?: string | undefined;
67
+ search?: string | undefined;
68
+ status?: WorkspaceStatus | undefined;
69
+ limit: number;
70
+ cursor?: StringIdCursor | undefined;
71
+ }
72
+
73
+ export interface ListAdminWorkspaceResult {
74
+ workspaces: AdminWorkspaceRow[];
75
+ nextCursor: StringIdCursor | null;
76
+ }
77
+
78
+ function adminWorkspaceCursorWhere(cursor: StringIdCursor | undefined): SQL | undefined {
79
+ if (!cursor) return undefined;
80
+ return or(
81
+ gt(workspaces.name, cursor.value),
82
+ and(eq(workspaces.name, cursor.value), gt(workspaces.id, cursor.id)),
83
+ );
84
+ }
85
+
86
+ export async function listAdminWorkspaces(
87
+ params: ListAdminWorkspaceParams,
88
+ ): Promise<ListAdminWorkspaceResult> {
89
+ const conditions = [] as SQL[];
90
+ if (params.workspaceId) conditions.push(eq(workspaces.id, params.workspaceId));
91
+ if (params.search) {
92
+ conditions.push(ilike(workspaces.name, `%${escapeIlikePattern(params.search)}%`));
93
+ }
94
+ if (params.status) conditions.push(eq(workspaces.status, params.status));
95
+ const cursor = adminWorkspaceCursorWhere(params.cursor);
96
+ if (cursor) conditions.push(cursor);
97
+
98
+ const rows = await db()
99
+ .select({workspace: workspaces})
100
+ .from(workspaces)
101
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
102
+ .orderBy(asc(workspaces.name), asc(workspaces.id))
103
+ .limit(params.limit + 1);
104
+
105
+ const hasMore = rows.length > params.limit;
106
+ const pageRows = hasMore ? rows.slice(0, params.limit) : rows;
107
+ const last = pageRows.at(-1);
108
+ const workspaceIds = pageRows.map((row) => row.workspace.id);
109
+ const memberCountRows =
110
+ workspaceIds.length > 0
111
+ ? await db()
112
+ .select({workspaceId: memberships.workspaceId, memberCount: count(memberships.id)})
113
+ .from(memberships)
114
+ .where(inArray(memberships.workspaceId, workspaceIds))
115
+ .groupBy(memberships.workspaceId)
116
+ : [];
117
+ const memberCounts = new Map(
118
+ memberCountRows.map((row) => [row.workspaceId, Number(row.memberCount)]),
119
+ );
120
+
121
+ return {
122
+ workspaces: pageRows.map((row) => ({
123
+ ...toWorkspace(row.workspace),
124
+ memberCount: memberCounts.get(row.workspace.id) ?? 0,
125
+ })),
126
+ nextCursor: hasMore && last ? {value: last.workspace.name, id: last.workspace.id} : null,
127
+ };
128
+ }
129
+
130
+ function escapeIlikePattern(value: string): string {
131
+ return value.replace(/[\\%_]/g, (char) => `\\${char}`);
132
+ }
133
+
60
134
  export interface WorkspaceServiceMetrics {
61
135
  activeWorkspaces: number;
62
136
  memberships: number;
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
2
+ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
3
+ import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
1
4
  import {
2
5
  WORKSPACES_INVITATION_SEND_REQUESTED,
3
6
  type WorkspacesEventMap,
@@ -9,7 +12,7 @@ import {db, migrationsPath, workspacesOutbox} from '#db/index.js';
9
12
  import {registerWorkspacesServiceMetrics} from '#metrics/index.js';
10
13
  import {workspacesE2eRoutes} from '#presentation/e2eRoutes/index.js';
11
14
  import {createWorkspacesInterModulePresentation} from '#presentation/inter-module.js';
12
- import {workspacesRoutes} from '#presentation/routes/index.js';
15
+ import {createWorkspacesRoutes, workspacesRoutes} from '#presentation/routes/index.js';
13
16
  import {onInvitationSendRequested} from '#presentation/subscribers/index.js';
14
17
 
15
18
  export type {Invitation} from '#core/entities/invitation.js';
@@ -32,10 +35,32 @@ export {type EnsureMembershipParams, ensureMembership} from '#core/memberships.j
32
35
  export {getWorkspace, requireWorkspaceMembership} from '#core/workspaces.js';
33
36
  export {db, migrationsPath} from '#db/index.js';
34
37
  export {listMembershipsByUser} from '#db/memberships.js';
35
- export {workspacesRoutes as routes} from '#presentation/routes/index.js';
38
+ export {createWorkspacesRoutes, workspacesRoutes as routes} from '#presentation/routes/index.js';
36
39
 
37
40
  const subscriber = subscriberFactory<WorkspacesEventMap>();
38
41
 
42
+ export interface WorkspacesModuleOptions {
43
+ auth: AuthInterModuleClient;
44
+ projects: ProjectsModuleClient;
45
+ runners: RunnersInterModuleClient;
46
+ }
47
+
48
+ export function createWorkspacesModule(options: WorkspacesModuleOptions): ShipfoxModule {
49
+ return {
50
+ name: 'workspaces',
51
+ database: {db, migrationsPath, databaseNamespace: 'workspaces'},
52
+ routes: createWorkspacesRoutes(options),
53
+ e2eRoutes: [workspacesE2eRoutes],
54
+ publishers: [
55
+ {name: 'workspaces', table: workspacesOutbox, db, eventSchemas: workspacesEventSchemas},
56
+ ],
57
+ subscribers: [subscriber(WORKSPACES_INVITATION_SEND_REQUESTED, onInvitationSendRequested)],
58
+ metrics: registerWorkspacesServiceMetrics,
59
+ interModulePresentations: [createWorkspacesInterModulePresentation()],
60
+ };
61
+ }
62
+
63
+ /** @deprecated Use createWorkspacesModule from the application composition root. */
39
64
  export const workspacesModule: ShipfoxModule = {
40
65
  name: 'workspaces',
41
66
  database: {db, migrationsPath, databaseNamespace: 'workspaces'},
@@ -9,6 +9,7 @@ export function toMembershipWithWorkspaceDto(
9
9
  user_id: membership.userId,
10
10
  workspace_id: membership.workspaceId,
11
11
  workspace_name: membership.workspaceName,
12
+ workspace_status: membership.workspaceStatus,
12
13
  created_at: membership.createdAt.toISOString(),
13
14
  updated_at: membership.updatedAt.toISOString(),
14
15
  };
@@ -1 +1 @@
1
- export {workspacesRoutes as routes} from './routes/index.js';
1
+ export {createWorkspacesRoutes, workspacesRoutes as routes} from './routes/index.js';
@@ -0,0 +1,168 @@
1
+ import {AUTH_USER, buildUserContext, setUserContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ type AuthInterModuleClient,
4
+ authInterModuleContract,
5
+ } from '@shipfox/api-auth-dto/inter-module';
6
+ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
7
+ import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
8
+ import {createInterModuleKnownError} from '@shipfox/inter-module';
9
+ import {type AuthMethod, closeApp, createApp} from '@shipfox/node-fastify';
10
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
11
+ import {createMembership} from '#db/memberships.js';
12
+ import {createWorkspace} from '#db/workspaces.js';
13
+ import {createAdminWorkspacesRoutes} from './admin-workspaces.js';
14
+
15
+ const USER_ID = '11111111-1111-4111-8111-111111111111';
16
+
17
+ const fakeUserAuth: AuthMethod = {
18
+ name: AUTH_USER,
19
+ authenticate: (request: FastifyRequest) => {
20
+ setUserContext(
21
+ request,
22
+ buildUserContext({userId: USER_ID, email: 'admin@example.com', memberships: []}),
23
+ );
24
+ return Promise.resolve();
25
+ },
26
+ };
27
+
28
+ describe('GET /admin/workspaces', () => {
29
+ let app: FastifyInstance;
30
+ let auth: AuthInterModuleClient;
31
+ let projects: ProjectsModuleClient;
32
+ let runners: RunnersInterModuleClient;
33
+
34
+ beforeEach(async () => {
35
+ await closeApp();
36
+ auth = {
37
+ requireAdminRole: vi.fn().mockResolvedValue({role: 'admin-observer'}),
38
+ } as unknown as AuthInterModuleClient;
39
+ projects = {
40
+ getWorkspaceProjectCounts: vi.fn().mockResolvedValue({counts: []}),
41
+ } as unknown as ProjectsModuleClient;
42
+ runners = {
43
+ getWorkspaceJobCounts: vi.fn().mockResolvedValue({counts: []}),
44
+ } as unknown as RunnersInterModuleClient;
45
+ app = await createApp({
46
+ auth: [fakeUserAuth],
47
+ routes: [createAdminWorkspacesRoutes({auth, projects, runners})],
48
+ swagger: false,
49
+ });
50
+ await app.ready();
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await closeApp();
55
+ });
56
+
57
+ test('returns a bounded safe workspace summary for an observer', async () => {
58
+ const workspace = await createWorkspace({name: `Admin lookup ${crypto.randomUUID()}`});
59
+ await createMembership({userId: crypto.randomUUID(), workspaceId: workspace.id});
60
+ vi.mocked(projects.getWorkspaceProjectCounts).mockResolvedValue({
61
+ counts: [{workspaceId: workspace.id, count: 3}],
62
+ });
63
+ vi.mocked(runners.getWorkspaceJobCounts).mockResolvedValue({
64
+ counts: [{workspaceId: workspace.id, queued: 2, running: 1}],
65
+ });
66
+
67
+ const response = await app.inject({
68
+ method: 'GET',
69
+ url: `/admin/workspaces?workspace_id=${workspace.id}`,
70
+ headers: {authorization: 'Bearer user'},
71
+ });
72
+
73
+ expect(response.statusCode).toBe(200);
74
+ expect(response.json()).toMatchObject({
75
+ workspaces: [
76
+ {
77
+ id: workspace.id,
78
+ name: workspace.name,
79
+ status: 'active',
80
+ member_summary: {count: 1},
81
+ project_summary: {state: 'available', count: 3},
82
+ job_counts: {state: 'available', queued: 2, running: 1},
83
+ },
84
+ ],
85
+ next_cursor: null,
86
+ });
87
+ expect(response.json().workspaces[0]).not.toHaveProperty('settings');
88
+ expect(response.json().workspaces[0]).not.toHaveProperty('administrator');
89
+ expect(auth.requireAdminRole).toHaveBeenCalledWith({
90
+ userId: USER_ID,
91
+ minimumRole: 'admin-observer',
92
+ });
93
+ });
94
+
95
+ test('returns explicit unknown job counts when the supporting lookup fails', async () => {
96
+ const workspace = await createWorkspace({name: `Unknown jobs ${crypto.randomUUID()}`});
97
+ vi.mocked(projects.getWorkspaceProjectCounts).mockResolvedValue({
98
+ counts: [{workspaceId: workspace.id, count: 0}],
99
+ });
100
+ vi.mocked(runners.getWorkspaceJobCounts).mockRejectedValue(new Error('runner service down'));
101
+
102
+ const response = await app.inject({
103
+ method: 'GET',
104
+ url: `/admin/workspaces?workspace_id=${workspace.id}`,
105
+ headers: {authorization: 'Bearer user'},
106
+ });
107
+
108
+ expect(response.statusCode).toBe(200);
109
+ expect(response.json().workspaces[0]).toMatchObject({
110
+ project_summary: {state: 'available', count: 0},
111
+ job_counts: {state: 'unknown'},
112
+ });
113
+ });
114
+
115
+ test('keeps known supporting counts when a response omits another workspace', async () => {
116
+ const prefix = `Partial counts ${crypto.randomUUID()}`;
117
+ const firstWorkspace = await createWorkspace({name: `${prefix} Alpha`});
118
+ const secondWorkspace = await createWorkspace({name: `${prefix} Beta`});
119
+ vi.mocked(projects.getWorkspaceProjectCounts).mockResolvedValue({
120
+ counts: [{workspaceId: firstWorkspace.id, count: 2}],
121
+ });
122
+ vi.mocked(runners.getWorkspaceJobCounts).mockResolvedValue({
123
+ counts: [{workspaceId: firstWorkspace.id, queued: 1, running: 2}],
124
+ });
125
+
126
+ const response = await app.inject({
127
+ method: 'GET',
128
+ url: `/admin/workspaces?search=${encodeURIComponent(prefix)}`,
129
+ headers: {authorization: 'Bearer user'},
130
+ });
131
+
132
+ expect(response.statusCode).toBe(200);
133
+ expect(response.json().workspaces).toEqual([
134
+ expect.objectContaining({
135
+ id: firstWorkspace.id,
136
+ project_summary: {state: 'available', count: 2},
137
+ job_counts: {state: 'available', queued: 1, running: 2},
138
+ }),
139
+ expect.objectContaining({
140
+ id: secondWorkspace.id,
141
+ project_summary: {state: 'unknown'},
142
+ job_counts: {state: 'unknown'},
143
+ }),
144
+ ]);
145
+ });
146
+
147
+ test('maps an insufficient administrator role to forbidden', async () => {
148
+ vi.mocked(auth.requireAdminRole).mockRejectedValue(
149
+ createInterModuleKnownError(
150
+ authInterModuleContract.methods.requireAdminRole,
151
+ 'admin-role-required',
152
+ {requiredRole: 'admin-observer'},
153
+ ),
154
+ );
155
+
156
+ const response = await app.inject({
157
+ method: 'GET',
158
+ url: '/admin/workspaces',
159
+ headers: {authorization: 'Bearer user'},
160
+ });
161
+
162
+ expect(response.statusCode).toBe(403);
163
+ expect(response.json()).toMatchObject({
164
+ code: 'forbidden',
165
+ details: {required_role: 'admin-observer'},
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,93 @@
1
+ import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ type AuthInterModuleClient,
4
+ authInterModuleContract,
5
+ } from '@shipfox/api-auth-dto/inter-module';
6
+ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
7
+ import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
8
+ import {
9
+ listWorkspaceAdminSummariesResponseSchema,
10
+ workspaceAdminLookupQuerySchema,
11
+ } from '@shipfox/api-workspaces-dto';
12
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
13
+ import {decodeStringIdCursor, encodeStringIdCursor} from '@shipfox/node-drizzle';
14
+ import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
15
+ import {
16
+ listWorkspaceAdministratorSummaries,
17
+ type WorkspaceAdministratorSummary,
18
+ } from '#core/admin-workspaces.js';
19
+
20
+ function toWorkspaceAdministratorSummaryDto(summary: WorkspaceAdministratorSummary) {
21
+ return {
22
+ id: summary.id,
23
+ name: summary.name,
24
+ status: summary.status,
25
+ member_summary: summary.memberSummary,
26
+ project_summary: summary.projectSummary,
27
+ job_counts: summary.jobCounts,
28
+ created_at: summary.createdAt.toISOString(),
29
+ updated_at: summary.updatedAt.toISOString(),
30
+ };
31
+ }
32
+
33
+ export function createAdminWorkspacesRoutes(params: {
34
+ auth: AuthInterModuleClient;
35
+ projects: ProjectsModuleClient;
36
+ runners: RunnersInterModuleClient;
37
+ }): RouteGroup {
38
+ const listRoute = defineRoute({
39
+ method: 'GET',
40
+ path: '/',
41
+ description: 'List bounded safe workspace summaries for administrators.',
42
+ auth: AUTH_USER,
43
+ schema: {
44
+ querystring: workspaceAdminLookupQuerySchema,
45
+ response: {200: listWorkspaceAdminSummariesResponseSchema},
46
+ },
47
+ errorHandler: (error) => {
48
+ if (
49
+ isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error) &&
50
+ error.code === 'admin-role-required'
51
+ ) {
52
+ throw new ClientError('Administrator observer role required', 'forbidden', {
53
+ status: 403,
54
+ details: {required_role: error.details.requiredRole},
55
+ });
56
+ }
57
+ throw error;
58
+ },
59
+ handler: async (request) => {
60
+ const client = getUserContext(request);
61
+ if (!client) {
62
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
63
+ }
64
+ await params.auth.requireAdminRole({
65
+ userId: client.userId,
66
+ minimumRole: 'admin-observer',
67
+ });
68
+
69
+ const {workspace_id: workspaceId, search, status, limit, cursor} = request.query;
70
+ const decodedCursor = decodeStringIdCursor(cursor);
71
+ if (cursor && !decodedCursor) {
72
+ throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
73
+ }
74
+
75
+ const result = await listWorkspaceAdministratorSummaries({
76
+ workspaceId,
77
+ search,
78
+ status,
79
+ limit,
80
+ cursor: decodedCursor,
81
+ projects: params.projects,
82
+ runners: params.runners,
83
+ });
84
+
85
+ return {
86
+ workspaces: result.workspaces.map(toWorkspaceAdministratorSummaryDto),
87
+ next_cursor: result.nextCursor ? encodeStringIdCursor(result.nextCursor) : null,
88
+ };
89
+ },
90
+ });
91
+
92
+ return {prefix: '/admin/workspaces', routes: [listRoute]};
93
+ }
@@ -1,4 +1,8 @@
1
+ import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
2
+ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
3
+ import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
1
4
  import type {RouteGroup} from '@shipfox/node-fastify';
5
+ import {createAdminWorkspacesRoutes} from './admin-workspaces.js';
2
6
  import {invitationsAcceptGroup, invitationsWorkspaceScopedRoutes} from './invitations/index.js';
3
7
  import {memberRoutes} from './members/index.js';
4
8
  import {createWorkspaceRoute, listUserWorkspacesRoute} from './workspaces/index.js';
@@ -21,3 +25,11 @@ export const workspacesRoutes: RouteGroup[] = [
21
25
  ],
22
26
  },
23
27
  ];
28
+
29
+ export function createWorkspacesRoutes(params: {
30
+ auth: AuthInterModuleClient;
31
+ projects: ProjectsModuleClient;
32
+ runners: RunnersInterModuleClient;
33
+ }): RouteGroup[] {
34
+ return [...workspacesRoutes, createAdminWorkspacesRoutes(params)];
35
+ }