import { z } from "zod"; import { MAX_COLLECTION_LIST_COLUMNS } from "../../schema/types.js"; import { compileUrlPattern } from "../../schema/url-pattern.js"; import { slugPattern } from "./common.js"; // --------------------------------------------------------------------------- // Schema (collections & fields): Input schemas // --------------------------------------------------------------------------- const collectionSupportValues = z.enum([ "drafts", "revisions", "preview", "scheduling", "search", "seo", ]); const collectionSourcePattern = /^(template:.+|import:.+|manual|discovered|seed)$/; const collectionListColumns = z.array( z.string().min(1).max(63).regex(slugPattern, "Invalid field slug format"), ); const collectionAdminInputConfig = z.object({ listColumns: collectionListColumns .max( MAX_COLLECTION_LIST_COLUMNS, `At most ${MAX_COLLECTION_LIST_COLUMNS} list columns are allowed`, ) .optional(), }); const collectionAdminResponseConfig = z.object({ listColumns: collectionListColumns.optional(), }); const fieldTypeValues = z.enum([ "string", "text", "url", "number", "integer", "boolean", "datetime", "select", "multiSelect", "portableText", "image", "file", "reference", "json", "slug", "repeater", ]); const repeaterSubFieldSchema = z.object({ slug: z.string().min(1).max(63).regex(slugPattern, "Invalid slug format"), // Keep in sync with REPEATER_SUB_FIELD_TYPES in schema/types.ts. // ("url" was already a documented sub-field type but missing here.) type: z.enum([ "string", "text", "url", "number", "integer", "boolean", "datetime", "select", "image", ]), label: z.string().min(1), required: z.boolean().optional(), options: z.array(z.string()).optional(), }); const urlPatternValue = z.string().superRefine((pattern, ctx) => { try { compileUrlPattern(pattern); } catch { ctx.addIssue({ code: "custom", message: "Invalid URL pattern", }); } }); const fieldValidation = z .object({ required: z.boolean().optional(), min: z.number().optional(), max: z.number().optional(), minLength: z.number().int().min(0).optional(), maxLength: z.number().int().min(0).optional(), pattern: z.string().optional(), options: z.array(z.string()).optional(), subFields: z.array(repeaterSubFieldSchema).min(1).optional(), minItems: z.number().int().min(0).optional(), maxItems: z.number().int().min(1).optional(), allowedMimeTypes: z .array( z .string() .regex(/^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i, "Invalid MIME type"), ) .min(1, "allowedMimeTypes must not be empty — omit the field to allow all types") .max(64, "allowedMimeTypes may contain at most 64 entries") .optional(), }) .superRefine((validation, ctx) => { for (const [minimum, maximum] of [ ["min", "max"], ["minLength", "maxLength"], ["minItems", "maxItems"], ] as const) { const minimumValue = validation[minimum]; const maximumValue = validation[maximum]; if (minimumValue !== undefined && maximumValue !== undefined && minimumValue > maximumValue) { ctx.addIssue({ code: "custom", path: [maximum], message: `${maximum} must be greater than or equal to ${minimum}`, }); } } if (validation.pattern !== undefined) { try { RegExp(validation.pattern); } catch { ctx.addIssue({ code: "custom", path: ["pattern"], message: "Invalid validation pattern", }); } } }) .optional(); const fieldWidgetOptions = z.record(z.string(), z.unknown()).optional(); export const createCollectionBody = z .object({ slug: z.string().min(1).max(63).regex(slugPattern, "Invalid slug format"), label: z.string().min(1), labelSingular: z.string().optional(), description: z.string().optional(), icon: z.string().optional(), admin: collectionAdminInputConfig.optional(), supports: z.array(collectionSupportValues).optional(), source: z.string().regex(collectionSourcePattern).optional(), urlPattern: urlPatternValue.optional(), routable: z.boolean().optional(), hasSeo: z.boolean().optional(), hidden: z.boolean().optional(), sortOrder: z.number().int().nullish(), }) .meta({ id: "CreateCollectionBody" }); export const updateCollectionBody = z .object({ label: z.string().min(1).optional(), labelSingular: z.string().optional(), description: z.string().optional(), icon: z.string().optional(), admin: collectionAdminInputConfig.optional(), supports: z.array(collectionSupportValues).optional(), urlPattern: urlPatternValue.nullish(), routable: z.boolean().optional(), hasSeo: z.boolean().optional(), hidden: z.boolean().optional(), sortOrder: z.number().int().nullish(), commentsEnabled: z.boolean().optional(), commentsModeration: z.enum(["all", "first_time", "none"]).optional(), commentsClosedAfterDays: z.number().int().min(0).optional(), commentsAutoApproveUsers: z.boolean().optional(), }) .meta({ id: "UpdateCollectionBody" }); export const createFieldBody = z .object({ slug: z.string().min(1).max(63).regex(slugPattern, "Invalid slug format"), label: z.string().min(1), type: fieldTypeValues, required: z.boolean().optional(), unique: z.boolean().optional(), defaultValue: z.unknown().optional(), validation: fieldValidation.nullable(), widget: z.string().optional(), options: fieldWidgetOptions, sortOrder: z.number().int().min(0).optional(), searchable: z.boolean().optional(), indexed: z.boolean().optional(), translatable: z.boolean().optional(), }) .meta({ id: "CreateFieldBody" }); export const updateFieldBody = z .object({ label: z.string().min(1).optional(), type: fieldTypeValues.optional(), required: z.boolean().optional(), unique: z.boolean().optional(), defaultValue: z.unknown().optional(), validation: fieldValidation.nullable(), widget: z.string().optional(), options: fieldWidgetOptions, sortOrder: z.number().int().min(0).optional(), searchable: z.boolean().optional(), indexed: z.boolean().optional(), translatable: z.boolean().optional(), }) .meta({ id: "UpdateFieldBody" }); export const fieldReorderBody = z .object({ fieldSlugs: z.array(z.string().min(1)), }) .meta({ id: "FieldReorderBody" }); export const collectionReorderBody = z .object({ /** Full desired sidebar order. Collections left out fall back to alphabetical. */ slugs: z.array(z.string().min(1)), }) .meta({ id: "CollectionReorderBody" }); export const orphanRegisterBody = z .object({ label: z.string().optional(), labelSingular: z.string().optional(), description: z.string().optional(), }) .meta({ id: "OrphanRegisterBody" }); export const schemaExportQuery = z.object({ format: z.string().optional(), }); export const collectionGetQuery = z.object({ includeFields: z .string() .transform((v) => v === "true") .optional(), }); // --------------------------------------------------------------------------- // Schema: Response schemas // --------------------------------------------------------------------------- export const collectionSchema = z .object({ id: z.string(), slug: z.string(), label: z.string(), labelSingular: z.string().nullable(), description: z.string().nullable(), icon: z.string().nullable(), admin: collectionAdminResponseConfig.optional(), supports: z.array(z.string()), source: z.string().nullable(), urlPattern: z.string().nullable(), routable: z.boolean(), hasSeo: z.boolean(), hidden: z.boolean(), sortOrder: z.number().int().nullable(), createdAt: z.string(), updatedAt: z.string(), }) .meta({ id: "Collection" }); export const fieldSchema = z .object({ id: z.string(), collectionId: z.string(), slug: z.string(), label: z.string(), type: fieldTypeValues, required: z.boolean(), unique: z.boolean(), defaultValue: z.unknown().nullable(), validation: z.record(z.string(), z.unknown()).nullable(), widget: z.string().nullable(), options: z.record(z.string(), z.unknown()).nullable(), sortOrder: z.number().int(), searchable: z.boolean(), indexed: z.boolean(), translatable: z.boolean(), createdAt: z.string(), updatedAt: z.string(), }) .meta({ id: "Field" }); export const collectionResponseSchema = z .object({ item: collectionSchema }) .meta({ id: "CollectionResponse" }); export const collectionWithFieldsResponseSchema = z .object({ item: collectionSchema.extend({ fields: z.array(fieldSchema) }), }) .meta({ id: "CollectionWithFieldsResponse" }); export const collectionListResponseSchema = z .object({ items: z.array(collectionSchema) }) .meta({ id: "CollectionListResponse" }); export const fieldResponseSchema = z.object({ item: fieldSchema }).meta({ id: "FieldResponse" }); export const fieldListResponseSchema = z .object({ items: z.array(fieldSchema) }) .meta({ id: "FieldListResponse" }); export const orphanedTableSchema = z .object({ slug: z.string(), tableName: z.string(), rowCount: z.number().int(), }) .meta({ id: "OrphanedTable" }); export const orphanedTableListResponseSchema = z .object({ items: z.array(orphanedTableSchema) }) .meta({ id: "OrphanedTableListResponse" });