@manablox/api-rpc 0.1.0 → 0.2.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.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # `@manablox/api-rpc`
2
+
3
+ The management API as an oRPC router: input schemas, permission middleware and one service call per procedure. The admin imports `ManabloxRouter` as a type and gets an end-to-end typed client with no code generation; `apps/api` also serves the same router as REST with an OpenAPI document.
4
+
5
+ ## Exports
6
+
7
+ - `router` / `ManabloxRouter`
8
+ - `base`, `authed`, `scoped(permission)`, `superadmin` — the procedure builders
9
+ - `RpcContext`, `RpcRuntime`, `pickRpcRuntime`
10
+ - `schemas` — `uuid`, `locale`, `machineName`, `pagination()`, …
11
+
12
+ ## Depends on
13
+
14
+ @manablox/services, @manablox/auth, @orpc/server, zod
15
+
16
+ ## Test
17
+
18
+ ```sh
19
+ pnpm --filter @manablox/api-rpc test
20
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manablox/api-rpc",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,11 +11,12 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "dependencies": {
14
- "@manablox/auth": "0.1.0",
15
- "@manablox/core": "0.1.0",
16
- "@manablox/db": "0.1.0",
17
- "@manablox/media": "0.1.0",
18
- "@manablox/services": "0.1.0",
14
+ "@manablox/auth": "0.2.0",
15
+ "@manablox/core": "0.2.0",
16
+ "@manablox/db": "0.2.0",
17
+ "@manablox/media": "0.2.0",
18
+ "@manablox/services": "0.2.0",
19
+ "@manablox/workflows": "0.2.0",
19
20
  "@orpc/server": "^1.15.0",
20
21
  "@orpc/openapi": "^1.15.0",
21
22
  "zod": "^4.5.4"
@@ -27,6 +28,7 @@
27
28
  "vitest": "^5.0.0"
28
29
  },
29
30
  "scripts": {
30
- "typecheck": "tsc --noEmit"
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "vitest run"
31
33
  }
32
34
  }
package/src/base.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { assertCan, type Permission } from '@manablox/auth';
2
- import { ManabloxError } from '@manablox/core';
2
+ import { ManabloxError, TRANSPORT_CODE } from '@manablox/core';
3
3
  import { ORPCError, os } from '@orpc/server';
4
4
  import type { RpcContext } from './context.js';
5
5
 
@@ -18,19 +18,7 @@ export const base = os.$context<RpcContext>().use(async ({ next }) => {
18
18
  export function toOrpcError(error: unknown): unknown {
19
19
  if (!ManabloxError.is(error)) return error;
20
20
 
21
- const code = (
22
- {
23
- validation: 'BAD_REQUEST',
24
- bad_request: 'BAD_REQUEST',
25
- not_found: 'NOT_FOUND',
26
- conflict: 'CONFLICT',
27
- forbidden: 'FORBIDDEN',
28
- unauthorized: 'UNAUTHORIZED',
29
- internal: 'INTERNAL_SERVER_ERROR',
30
- } as const
31
- )[error.kind];
32
-
33
- return new ORPCError(code, {
21
+ return new ORPCError(TRANSPORT_CODE[error.kind], {
34
22
  message: error.key,
35
23
  // Structured details survive to the client: key, path and params per problem.
36
24
  data: { key: error.key, details: error.details },
@@ -51,7 +39,10 @@ export const authed = base.use(async ({ context, next }) => {
51
39
  */
52
40
  export function scoped(permission: Permission) {
53
41
  return authed.use(async ({ context, next }, input: unknown) => {
54
- const spaceId = (input as { spaceId?: string } | undefined)?.spaceId ?? null;
42
+ // The middleware sees the input before the schema runs, so a procedure whose space
43
+ // sits inside a `filter` object (the listings) is read there too.
44
+ const raw = input as { spaceId?: string; filter?: { spaceId?: string } } | undefined;
45
+ const spaceId = raw?.spaceId ?? raw?.filter?.spaceId ?? null;
55
46
  assertCan(context.principal, spaceId, permission);
56
47
  return next();
57
48
  });
package/src/context.ts CHANGED
@@ -1,10 +1,22 @@
1
- import type { ApiKeyService, ManabloxAuth, Principal } from '@manablox/auth';
1
+ import type { ApiKeyService, ManabloxAuth, Principal, UserService } from '@manablox/auth';
2
2
  import type { Manablox } from '@manablox/core';
3
3
  import type { Repositories } from '@manablox/db';
4
4
  import type { MediaService } from '@manablox/media';
5
- import type { ContentService, ContentTypeService, Loaders } from '@manablox/services';
5
+ import type {
6
+ ContentService,
7
+ ContentTypeService,
8
+ Loaders,
9
+ MenuService,
10
+ RoleService,
11
+ SpaceService,
12
+ } from '@manablox/services';
13
+ import type { WorkflowService } from '@manablox/workflows';
6
14
 
7
- export interface RpcContext {
15
+ /**
16
+ * The part of the host's runtime the management procedures reach for. The host's own
17
+ * `Runtime` extends this, so adding a service there and here is the whole change.
18
+ */
19
+ export interface RpcRuntime {
8
20
  manablox: Manablox;
9
21
  repos: Repositories;
10
22
  auth: ManabloxAuth;
@@ -12,6 +24,46 @@ export interface RpcContext {
12
24
  media: MediaService;
13
25
  content: ContentService;
14
26
  contentTypes: ContentTypeService;
27
+ spaces: SpaceService;
28
+ users: UserService;
29
+ menus: MenuService;
30
+ roles: RoleService;
31
+ workflows: WorkflowService;
32
+ }
33
+
34
+ /** The runtime's RPC-facing slice, without whatever else the host keeps on it. */
35
+ export function pickRpcRuntime(runtime: RpcRuntime): RpcRuntime {
36
+ const {
37
+ manablox,
38
+ repos,
39
+ auth,
40
+ apiKeys,
41
+ media,
42
+ content,
43
+ contentTypes,
44
+ spaces,
45
+ users,
46
+ menus,
47
+ roles,
48
+ workflows,
49
+ } = runtime;
50
+ return {
51
+ manablox,
52
+ repos,
53
+ auth,
54
+ apiKeys,
55
+ media,
56
+ content,
57
+ contentTypes,
58
+ spaces,
59
+ users,
60
+ menus,
61
+ roles,
62
+ workflows,
63
+ };
64
+ }
65
+
66
+ export interface RpcContext extends RpcRuntime {
15
67
  loaders: Loaders;
16
68
  principal: Principal | null;
17
69
  headers: Headers;
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { assetRouter } from './routers/asset.js';
2
2
  import { contentRouter } from './routers/content.js';
3
3
  import { contentTypeRouter } from './routers/content-type.js';
4
+ import { menuRouter } from './routers/menu.js';
5
+ import { roleRouter } from './routers/role.js';
4
6
  import { spaceRouter } from './routers/space.js';
5
7
  import { userRouter } from './routers/user.js';
8
+ import { workflowRouter } from './routers/workflow.js';
6
9
 
7
10
  export * from './base.js';
8
11
  export * from './context.js';
@@ -17,6 +20,9 @@ export const router = {
17
20
  spaces: spaceRouter,
18
21
  assets: assetRouter,
19
22
  users: userRouter,
23
+ menus: menuRouter,
24
+ roles: roleRouter,
25
+ workflows: workflowRouter,
20
26
  };
21
27
 
22
28
  export type ManabloxRouter = typeof router;
@@ -1,17 +1,27 @@
1
1
  import { z } from 'zod';
2
2
  import { scoped } from '../base.js';
3
+ import { pagination, searchTerm, uuid } from '../schemas.js';
3
4
 
4
- const uuid = z.string().uuid();
5
+ const crop = z.object({
6
+ left: z.number().int().min(0),
7
+ top: z.number().int().min(0),
8
+ width: z.number().int().min(1),
9
+ height: z.number().int().min(1),
10
+ });
11
+ const focalPoint = z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1) });
5
12
 
6
13
  export const assetRouter = {
14
+ /** What an upload into this space is held to, and the instance's ceiling above it. */
15
+ limits: scoped('asset:read')
16
+ .input(z.object({ spaceId: uuid }))
17
+ .handler(async ({ input, context }) => context.media.limits(input.spaceId)),
18
+
7
19
  list: scoped('asset:read')
8
20
  .input(
9
- z.object({
21
+ pagination({ limit: 40, max: 100 }).extend({
10
22
  spaceId: uuid,
11
23
  mimeType: z.string().optional(),
12
- search: z.string().max(200).optional(),
13
- limit: z.number().int().min(1).max(100).default(40),
14
- offset: z.number().int().min(0).default(0),
24
+ search: searchTerm.optional(),
15
25
  }),
16
26
  )
17
27
  .handler(async ({ input, context }) => {
@@ -23,24 +33,22 @@ export const assetRouter = {
23
33
  },
24
34
  { limit: input.limit, offset: input.offset },
25
35
  );
26
- return {
27
- ...page,
28
- items: page.items.map((asset) => ({
29
- ...asset,
30
- url: context.media.urlFor(asset),
31
- thumbnailUrl: asset.mimeType.startsWith('image/')
32
- ? context.media.urlFor(asset, 'thumb', 'webp')
33
- : null,
34
- })),
35
- };
36
+ return { ...page, items: page.items.map((asset) => context.media.present(asset)) };
37
+ }),
38
+
39
+ /** Several assets in one round trip, for a field that references them; missing ids are absent. */
40
+ getMany: scoped('asset:read')
41
+ .input(z.object({ spaceId: uuid, ids: z.array(uuid).max(200) }))
42
+ .handler(async ({ input, context }) => {
43
+ const rows = await context.repos.assets.findManyByIds(input.ids, input.spaceId);
44
+ return rows.map((asset) => context.media.present(asset));
36
45
  }),
37
46
 
38
47
  get: scoped('asset:read')
39
48
  .input(z.object({ spaceId: uuid, id: uuid }))
40
49
  .handler(async ({ input, context }) => {
41
50
  const asset = await context.repos.assets.findById(input.id);
42
- if (!asset) return null;
43
- return { ...asset, url: context.media.urlFor(asset) };
51
+ return asset ? context.media.present(asset) : null;
44
52
  }),
45
53
 
46
54
  update: scoped('asset:write')
@@ -58,6 +66,27 @@ export const assetRouter = {
58
66
  return context.repos.assets.update(id, data);
59
67
  }),
60
68
 
69
+ /**
70
+ * An image's crop and focal point. `null` clears one; omitting it also clears it, so
71
+ * the call always states the whole edit. Every variant re-renders through the result.
72
+ */
73
+ setImageEdits: scoped('asset:write')
74
+ .input(
75
+ z.object({
76
+ spaceId: uuid,
77
+ id: uuid,
78
+ crop: crop.nullable().optional(),
79
+ focalPoint: focalPoint.nullable().optional(),
80
+ }),
81
+ )
82
+ .handler(async ({ input, context }) => {
83
+ const asset = await context.media.setImageEdits(input.id, {
84
+ crop: input.crop ?? null,
85
+ focalPoint: input.focalPoint ?? null,
86
+ });
87
+ return context.media.present(asset);
88
+ }),
89
+
61
90
  delete: scoped('asset:delete')
62
91
  .input(z.object({ spaceId: uuid, id: uuid }))
63
92
  .handler(async ({ input, context }) => {
@@ -1,8 +1,7 @@
1
1
  import { renderContentTypeConfig } from '@manablox/services';
2
2
  import { z } from 'zod';
3
3
  import { base, scoped, superadmin } from '../base.js';
4
-
5
- const uuid = z.string().uuid();
4
+ import { uuid } from '../schemas.js';
6
5
 
7
6
  const fieldSchema = z.object({
8
7
  id: z.string().optional(),
@@ -51,7 +50,9 @@ export const contentTypeRouter = {
51
50
 
52
51
  create: scoped('contentType:write')
53
52
  .input(contentTypeSchema.extend({ spaceId: uuid }))
54
- .handler(async ({ input, context }) => context.contentTypes.create(input)),
53
+ .handler(async ({ input, context }) =>
54
+ context.contentTypes.create(input, context.principal.userId),
55
+ ),
55
56
 
56
57
  update: scoped('contentType:write')
57
58
  .input(contentTypeSchema.extend({ spaceId: uuid, id: uuid }))
@@ -1,18 +1,20 @@
1
- import { actorRoles } from '@manablox/auth';
1
+ import { actorRoles, allowedTypeIds, assertCan, type ContentPermission } from '@manablox/auth';
2
+ import { ManabloxError } from '@manablox/core';
2
3
  import { z } from 'zod';
3
- import { base, scoped } from '../base.js';
4
-
5
- const uuid = z.string().uuid();
4
+ import { base, scoped, toOrpcError } from '../base.js';
5
+ import type { RpcContext } from '../context.js';
6
+ import { locale, pagination, searchTerm, uuid } from '../schemas.js';
6
7
 
7
8
  const filterSchema = z.object({
8
9
  spaceId: uuid,
10
+ /** Specific documents, for a relation field's chips: one request instead of one per id. */
11
+ ids: z.array(uuid).max(200).optional(),
9
12
  typeIds: z.array(uuid).optional(),
10
- locale: z.string().optional(),
13
+ locale: locale.optional(),
11
14
  status: z.enum(['draft', 'published', 'archived']).optional(),
12
15
  parentId: uuid.nullable().optional(),
13
16
  under: uuid.optional(),
14
- search: z.string().max(200).optional(),
15
- visibleInMenu: z.boolean().optional(),
17
+ search: searchTerm.optional(),
16
18
  fields: z
17
19
  .array(
18
20
  z.object({
@@ -39,10 +41,7 @@ const filterSchema = z.object({
39
41
  .optional(),
40
42
  });
41
43
 
42
- const paginationSchema = z.object({
43
- limit: z.number().int().min(1).max(200).default(25),
44
- offset: z.number().int().min(0).default(0),
45
- });
44
+ const paginationSchema = pagination({ limit: 25, max: 200 });
46
45
 
47
46
  const sortSchema = z
48
47
  .array(
@@ -56,13 +55,12 @@ const sortSchema = z
56
55
  const saveSchema = z.object({
57
56
  spaceId: uuid,
58
57
  typeId: uuid,
59
- locale: z.string().min(2).max(10).default('en'),
58
+ locale: locale.default('en'),
60
59
  localizationId: uuid.optional(),
61
60
  parentId: uuid.nullable().optional(),
62
61
  title: z.string().min(1).max(500),
63
62
  slug: z.string().max(200).optional(),
64
63
  fields: z.record(z.string(), z.unknown()).default({}),
65
- visibleInMenu: z.boolean().optional(),
66
64
  position: z.number().int().optional(),
67
65
  expectedVersion: z.number().int().positive().optional(),
68
66
  });
@@ -78,20 +76,27 @@ export const contentRouter = {
78
76
  })
79
77
  .transform((v) => ({ ...v, spaceId: v.filter.spaceId })),
80
78
  )
81
- .handler(async ({ input, context }) =>
82
- context.content.list(
83
- input.filter,
79
+ .handler(async ({ input, context }) => {
80
+ // A role that reads type by type sees only those types; asked for one it may not
81
+ // read, the answer is an empty page rather than a refusal.
82
+ const filter = narrowToAllowed(context, input.filter);
83
+ if (!filter) {
84
+ const pagination = input.pagination ?? { limit: 25, offset: 0 };
85
+ return { items: [], total: 0, ...pagination };
86
+ }
87
+ return context.content.list(
88
+ filter,
84
89
  input.pagination ?? { limit: 25, offset: 0 },
85
90
  input.sort ?? [],
86
91
  { actor: toActor(context, input.filter.spaceId) },
87
- ),
88
- ),
92
+ );
93
+ }),
89
94
 
90
95
  tree: scoped('content:read')
91
96
  .input(
92
97
  z.object({
93
98
  spaceId: uuid,
94
- locale: z.string().default('en'),
99
+ locale: locale.default('en'),
95
100
  rootId: uuid.nullable().default(null),
96
101
  }),
97
102
  )
@@ -101,44 +106,53 @@ export const contentRouter = {
101
106
 
102
107
  get: scoped('content:read')
103
108
  .input(z.object({ spaceId: uuid, id: uuid }))
104
- .handler(async ({ input, context }) =>
105
- context.content.get(input.id, { actor: toActor(context, input.spaceId) }),
106
- ),
109
+ .handler(async ({ input, context }) => {
110
+ await assertOnDocument(context, input.spaceId, 'content:read', input.id);
111
+ return context.content.get(input.id, { actor: toActor(context, input.spaceId) });
112
+ }),
107
113
 
108
114
  /** Field values with defaults filled in — what the editor opens a new document with. */
109
115
  blank: scoped('content:read')
110
116
  .input(z.object({ spaceId: uuid, typeId: uuid }))
111
- .handler(async ({ input, context }) => ({
112
- fields: await context.content.initFields(context.manablox.contentTypes.get(input.typeId)),
113
- })),
117
+ .handler(async ({ input, context }) => {
118
+ assertOnType(context, input.spaceId, 'content:read', input.typeId);
119
+ return {
120
+ fields: await context.content.initFields(context.manablox.contentTypes.get(input.typeId)),
121
+ };
122
+ }),
114
123
 
115
124
  create: scoped('content:write')
116
125
  .input(saveSchema)
117
- .handler(async ({ input, context }) =>
118
- context.content.create(input, toActor(context, input.spaceId)),
119
- ),
126
+ .handler(async ({ input, context }) => {
127
+ assertOnType(context, input.spaceId, 'content:write', input.typeId);
128
+ return context.content.create(input, toActor(context, input.spaceId));
129
+ }),
120
130
 
121
131
  update: scoped('content:write')
122
132
  .input(saveSchema.extend({ id: uuid }))
123
- .handler(async ({ input, context }) =>
124
- context.content.update(input.id, input, toActor(context, input.spaceId)),
125
- ),
133
+ .handler(async ({ input, context }) => {
134
+ assertOnType(context, input.spaceId, 'content:write', input.typeId);
135
+ return context.content.update(input.id, input, toActor(context, input.spaceId));
136
+ }),
126
137
 
127
138
  delete: scoped('content:delete')
128
139
  .input(z.object({ spaceId: uuid, id: uuid }))
129
- .handler(async ({ input, context }) => ({
130
- deleted: await context.content.delete(input.id, toActor(context, input.spaceId)),
131
- })),
140
+ .handler(async ({ input, context }) => {
141
+ await assertOnDocument(context, input.spaceId, 'content:delete', input.id);
142
+ return { deleted: await context.content.delete(input.id, toActor(context, input.spaceId)) };
143
+ }),
132
144
 
133
145
  publish: scoped('content:publish')
134
146
  .input(z.object({ spaceId: uuid, id: uuid }))
135
- .handler(async ({ input, context }) =>
136
- context.content.publish(input.id, toActor(context, input.spaceId)),
137
- ),
147
+ .handler(async ({ input, context }) => {
148
+ await assertOnDocument(context, input.spaceId, 'content:publish', input.id);
149
+ return context.content.publish(input.id, toActor(context, input.spaceId));
150
+ }),
138
151
 
139
152
  unpublish: scoped('content:publish')
140
153
  .input(z.object({ spaceId: uuid, id: uuid }))
141
154
  .handler(async ({ input, context }) => {
155
+ await assertOnDocument(context, input.spaceId, 'content:publish', input.id);
142
156
  await context.content.unpublish(input.id, toActor(context, input.spaceId));
143
157
  return { ok: true };
144
158
  }),
@@ -153,9 +167,10 @@ export const contentRouter = {
153
167
  position: z.number().int().min(0),
154
168
  }),
155
169
  )
156
- .handler(async ({ input, context }) =>
157
- context.content.move(input.spaceId, input.id, input.parentId, input.position),
158
- ),
170
+ .handler(async ({ input, context }) => {
171
+ await assertOnDocument(context, input.spaceId, 'content:write', input.id);
172
+ return context.content.move(input.spaceId, input.id, input.parentId, input.position);
173
+ }),
159
174
 
160
175
  /** Every locale a document exists in, for the editor's language switcher. */
161
176
  translations: scoped('content:read')
@@ -164,15 +179,16 @@ export const contentRouter = {
164
179
 
165
180
  /** Starts a translation of an existing document, in the localization group it shares. */
166
181
  createTranslation: scoped('content:write')
167
- .input(z.object({ spaceId: uuid, id: uuid, locale: z.string().min(2).max(10) }))
168
- .handler(async ({ input, context }) =>
169
- context.content.createTranslation(
182
+ .input(z.object({ spaceId: uuid, id: uuid, locale }))
183
+ .handler(async ({ input, context }) => {
184
+ await assertOnDocument(context, input.spaceId, 'content:write', input.id);
185
+ return context.content.createTranslation(
170
186
  input.spaceId,
171
187
  input.id,
172
188
  input.locale,
173
189
  toActor(context, input.spaceId),
174
- ),
175
- ),
190
+ );
191
+ }),
176
192
 
177
193
  versions: scoped('content:read')
178
194
  .input(z.object({ spaceId: uuid, id: uuid }))
@@ -186,11 +202,61 @@ export const contentRouter = {
186
202
 
187
203
  restore: scoped('content:write')
188
204
  .input(z.object({ spaceId: uuid, id: uuid, version: z.number().int().positive() }))
189
- .handler(async ({ input, context }) =>
190
- context.content.restore(input.id, input.version, toActor(context, input.spaceId)),
191
- ),
205
+ .handler(async ({ input, context }) => {
206
+ await assertOnDocument(context, input.spaceId, 'content:write', input.id);
207
+ return context.content.restore(input.id, input.version, toActor(context, input.spaceId));
208
+ }),
192
209
  };
193
210
 
211
+ // ---------------------------------------------------------------------------
212
+ // Per-type permissions
213
+ //
214
+ // `scoped('content:…')` admits anyone whose role holds the action for *some* type; which
215
+ // type is only known once the input, or the document it names, is in hand. These finish
216
+ // the check.
217
+ // ---------------------------------------------------------------------------
218
+
219
+ function assertOnType(
220
+ context: RpcContext,
221
+ spaceId: string,
222
+ permission: ContentPermission,
223
+ typeId: string,
224
+ ): void {
225
+ try {
226
+ assertCan(context.principal, spaceId, permission, typeId);
227
+ } catch (error) {
228
+ throw toOrpcError(error);
229
+ }
230
+ }
231
+
232
+ async function assertOnDocument(
233
+ context: RpcContext,
234
+ spaceId: string,
235
+ permission: ContentPermission,
236
+ id: string,
237
+ ): Promise<void> {
238
+ // Only when the role narrows the action: the common case costs no extra read.
239
+ if (allowedTypeIds(context.principal, spaceId, permission) === null) return;
240
+ const row = await context.repos.content.findById(id);
241
+ if (!row || row.spaceId !== spaceId) {
242
+ throw toOrpcError(ManabloxError.notFound('content.notFound', { id }));
243
+ }
244
+ assertOnType(context, spaceId, permission, row.typeId);
245
+ }
246
+
247
+ /** The filter narrowed to the types the caller may read; `null` when that leaves none. */
248
+ function narrowToAllowed<T extends { spaceId: string; typeIds?: string[] | undefined }>(
249
+ context: RpcContext,
250
+ filter: T,
251
+ ): T | null {
252
+ const allowed = allowedTypeIds(context.principal, filter.spaceId, 'content:read');
253
+ if (allowed === null) return filter;
254
+ const typeIds = filter.typeIds?.length
255
+ ? filter.typeIds.filter((typeId) => allowed.includes(typeId))
256
+ : allowed;
257
+ return typeIds.length ? { ...filter, typeIds } : null;
258
+ }
259
+
194
260
  export { base };
195
261
 
196
262
  function toActor(context: { principal: unknown }, spaceId: string) {
@@ -0,0 +1,77 @@
1
+ import { z } from 'zod';
2
+ import { scoped } from '../base.js';
3
+ import { locale, machineName, uuid } from '../schemas.js';
4
+
5
+ const menuSchema = z.object({
6
+ name: z.string().min(1).max(200),
7
+ machineName,
8
+ description: z.string().max(2000).nullable().optional(),
9
+ });
10
+
11
+ /** One entry as the editor hands it over: a document by localization id, or a link. */
12
+ export interface MenuItemInputShape {
13
+ id?: string | undefined;
14
+ localizationId?: string | null | undefined;
15
+ label?: string | null | undefined;
16
+ url?: string | null | undefined;
17
+ children?: MenuItemInputShape[] | undefined;
18
+ }
19
+
20
+ const menuItemSchema: z.ZodType<MenuItemInputShape> = z.lazy(() =>
21
+ z.object({
22
+ id: uuid.optional(),
23
+ localizationId: uuid.nullable().optional(),
24
+ label: z.string().max(200).nullable().optional(),
25
+ url: z.string().max(2000).nullable().optional(),
26
+ children: z.array(menuItemSchema).max(500).optional(),
27
+ }),
28
+ );
29
+
30
+ /**
31
+ * Menus. Each rule — the unique machine name, what an entry may point at — lives in
32
+ * `MenuService`; a procedure here is an input schema, a permission and one call.
33
+ */
34
+ export const menuRouter = {
35
+ list: scoped('menu:read')
36
+ .input(z.object({ spaceId: uuid }))
37
+ .handler(async ({ input, context }) => context.menus.list(input.spaceId)),
38
+
39
+ /** The menu with its entries, each content entry resolved to the document in `locale`. */
40
+ get: scoped('menu:read')
41
+ .input(z.object({ spaceId: uuid, id: uuid, locale }))
42
+ .handler(async ({ input, context }) =>
43
+ context.menus.get(input.spaceId, input.id, input.locale),
44
+ ),
45
+
46
+ create: scoped('menu:write')
47
+ .input(menuSchema.extend({ spaceId: uuid }))
48
+ .handler(async ({ input, context }) => context.menus.create(input)),
49
+
50
+ update: scoped('menu:write')
51
+ .input(menuSchema.partial().extend({ spaceId: uuid, id: uuid }))
52
+ .handler(async ({ input, context }) => {
53
+ const { spaceId, id, ...data } = input;
54
+ return context.menus.update(spaceId, id, data);
55
+ }),
56
+
57
+ delete: scoped('menu:write')
58
+ .input(z.object({ spaceId: uuid, id: uuid }))
59
+ .handler(async ({ input, context }) => {
60
+ await context.menus.delete(input.spaceId, input.id);
61
+ return { ok: true };
62
+ }),
63
+
64
+ /** Replaces the whole entry tree; the editor saves a menu as one document. */
65
+ setItems: scoped('menu:write')
66
+ .input(z.object({ spaceId: uuid, id: uuid, items: z.array(menuItemSchema).max(500) }))
67
+ .handler(async ({ input, context }) =>
68
+ context.menus.setItems(input.spaceId, input.id, input.items),
69
+ ),
70
+
71
+ /** Menus a document is linked from, for the editor's hint. */
72
+ usedIn: scoped('menu:read')
73
+ .input(z.object({ spaceId: uuid, localizationId: uuid }))
74
+ .handler(async ({ input, context }) =>
75
+ context.menus.usedIn(input.spaceId, input.localizationId),
76
+ ),
77
+ };