feat(shared): extend the admin request contracts

Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
This commit is contained in:
jubnl
2026-08-02 15:07:23 +02:00
parent aec2d8cd87
commit ee13b6325b
2 changed files with 203 additions and 26 deletions
+85 -10
View File
@@ -3,31 +3,49 @@ import { z } from 'zod';
/**
* Admin API contract for /api/admin (admin-only).
*
* The admin service validates most bodies itself (returning {error,status}), so
* these schemas pin the well-defined ones: user create/update, the permission
* matrix, invites and the boolean feature toggles. Free-form bodies (OIDC
* settings, addon config, default user settings) stay with the service.
* These schemas exist to satisfy the body-contract ratchet without changing
* behavior, so they are deliberately permissive: wherever AdminService already
* emits a bespoke 400 of its own ('Invalid role', 'Name is required',
* 'Username, email and password are required'), the field stays optional and
* loosely typed so that error survives byte-identically. Every object schema is
* non-strict — several clients and tests send extra keys and expect success.
*/
export const adminUserCreateRequestSchema = z.object({
email: z.string(),
password: z.string().optional(),
username: z.string().optional(),
role: z.enum(['user', 'admin']).optional(),
email: z.string().optional(),
password: z.string().optional(),
// Deliberately z.string(), not z.enum: the service's own 'Invalid role' 400
// is the pinned contract for a bad role.
role: z.string().optional(),
});
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>;
export const adminUserUpdateRequestSchema = z.object({
username: z.string().optional(),
email: z.string().optional(),
password: z.string().optional(),
role: z.string().optional(),
});
export type AdminUserUpdateRequest = z.infer<typeof adminUserUpdateRequestSchema>;
// Values stay z.unknown(): PermissionsService.savePermissions reports unknown
// levels through its `skipped` list (a 200 response field the admin UI shows),
// so narrowing to z.string() would turn that into a 400.
export const adminPermissionsRequestSchema = z.object({
permissions: z.record(z.string(), z.unknown()),
});
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
export const adminInviteCreateRequestSchema = z.object({
max_uses: z.number().optional(),
expires_in_days: z.number().optional(),
// string | number: the service parses with parseInt(String(x)), and the
// admin UI sends numbers while older clients sent strings.
max_uses: z.union([z.number(), z.string()]).optional(),
expires_in_days: z.union([z.number(), z.string()]).optional(),
role: z.enum(['user', 'admin']).optional(),
// Optional trip binding (#1402): a user who registers via the link is
// auto-added to this trip. Nullable/absent = a plain registration invite.
trip_id: z.number().int().positive().nullable().optional(),
trip_id: z.union([z.number(), z.string()]).nullable().optional(),
});
export type AdminInviteCreateRequest = z.infer<typeof adminInviteCreateRequestSchema>;
@@ -35,3 +53,60 @@ export const adminFeatureToggleRequestSchema = z.object({
enabled: z.boolean(),
});
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
// Shared by all six packing-template create/update routes. `name` is optional so
// the service's 'Name is required' / 'Category name is required' / 'Item name is
// required' 400s stay the contract, and so the update routes keep treating a
// blank name as a no-op.
export const adminTemplateNameRequestSchema = z.object({
name: z.string().optional(),
});
export type AdminTemplateNameRequest = z.infer<typeof adminTemplateNameRequestSchema>;
export const adminOidcUpdateRequestSchema = z.object({
issuer: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
display_name: z.string().optional(),
discovery_url: z.string().optional(),
});
export type AdminOidcUpdateRequest = z.infer<typeof adminOidcUpdateRequestSchema>;
// `config` must stay free-form: each addon stores its own keys, and the AI addon
// round-trips an apiKey mask sentinel that a narrower shape would strip.
export const adminAddonUpdateRequestSchema = z.object({
enabled: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(),
});
export type AdminAddonUpdateRequest = z.infer<typeof adminAddonUpdateRequestSchema>;
// Fully partial — the client PUTs one computed key at a time.
export const adminCollabFeaturesRequestSchema = z.object({
chat: z.boolean().optional(),
notes: z.boolean().optional(),
polls: z.boolean().optional(),
whatsnext: z.boolean().optional(),
});
export type AdminCollabFeaturesRequest = z.infer<typeof adminCollabFeaturesRequestSchema>;
// event type -> channel id -> enabled. Anything narrower would silently drop
// plugin-contributed channels, since the client echoes back the whole matrix.
export const adminNotificationPreferencesRequestSchema = z.record(z.string(), z.record(z.string(), z.boolean()));
export type AdminNotificationPreferencesRequest = z.infer<typeof adminNotificationPreferencesRequestSchema>;
// Heterogeneous values, and `null` is meaningful — SettingsService treats it as
// "reset to the built-in default". z.record also rejects arrays and null bodies,
// preserving the route's object-only guard.
export const adminDefaultUserSettingsRequestSchema = z.record(z.string(), z.unknown());
export type AdminDefaultUserSettingsRequest = z.infer<typeof adminDefaultUserSettingsRequestSchema>;
// Dev-only test sender: every field optional (callers POST {}), and `inApp` is a
// boolean OR the in-app action descriptor the dev panel sends.
export const adminTestNotificationRequestSchema = z.object({
event: z.string().optional(),
scope: z.string().optional(),
targetId: z.number().optional(),
params: z.record(z.string(), z.unknown()).optional(),
inApp: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional(),
});
export type AdminTestNotificationRequest = z.infer<typeof adminTestNotificationRequestSchema>;