import { z } from 'zod'; import { AGG_FNS, COLUMN_TYPES, IMPORT_COLUMN_TYPES, PLAN_IDS, FILTER_OPS, SUPPORTED_LOCALES, SEQUENCE_MAX_LENGTH, STORED_COLUMN_TYPES, isCurrencyCode, isValidColumnName, isValidTableName, isValidWorkspaceSlug, } from '@wellformed/shared'; /** * How big an import may be, in the three places the number has to agree. * * A workbook is far denser than the CSV it would have been exported to — the * XML compresses, and a year of a real ledger arrives in a few hundred * kilobytes — so the file allowance is generous without being an invitation. * The body limit sits above both because JSON escaping and base64 both add to * what is actually sent. */ export const IMPORT_TEXT_BYTES = 4 * 1024 * 1024; export const IMPORT_FILE_BASE64 = 8 * 1024 * 1024; export const IMPORT_BODY_LIMIT = 12 * 1024 * 1024; export const tableNameSchema = z.string().refine(isValidTableName, { message: 'Invalid table name.', }); export const columnNameSchema = z.string().refine(isValidColumnName, { message: 'Invalid column name.', }); export const rowIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Invalid row id.'); export const filterSchema = z.object({ col: columnNameSchema, op: z.enum(FILTER_OPS), val: z.string().max(500).default(''), }); export const sortSchema = z.object({ col: columnNameSchema, dir: z.enum(['asc', 'desc']), }); export const formatSchema = z.enum(['currency', 'number', 'percent', 'date', 'text']).nullable(); /** The aggregate row: at most one function per column, and no more than a table's worth. */ export const summariesSchema = z .record(columnNameSchema, z.enum(AGG_FNS)) .refine((s) => Object.keys(s).length <= 100, 'Too many summarised columns.'); export const rowsQuerySchema = z.object({ view: z.string().max(64).optional(), /** JSON-encoded `Filter[]`; the grid builds these client-side. */ filters: z.string().max(4000).optional(), sort: z.string().max(200).optional(), /** JSON-encoded `string[]`: columns to leave out of the SELECT, overriding the view's. */ hidden: z.string().max(4000).optional(), q: z.string().max(200).optional(), limit: z.coerce.number().int().min(1).max(1000).default(200), offset: z.coerce.number().int().min(0).max(1_000_000).default(0), }); /** * The aggregate row asks over the same rows as the grid, so it takes the same * view, filters and search — and no page, because it summarises all of them. * Which functions to compute travels with the request rather than being read * off the view, so the footer answers while you are still choosing. */ export const summaryQuerySchema = z.object({ view: z.string().max(64).optional(), filters: z.string().max(4000).optional(), /** JSON-encoded `string[]`, so a search scopes to the columns the grid shows. */ hidden: z.string().max(4000).optional(), q: z.string().max(200).optional(), /** JSON-encoded `Summaries`. */ summaries: z.string().max(4000).optional(), }); /** What a column is called, in whoever's words. Identifiers are derived from it. */ const columnLabelSchema = z.string().trim().min(1).max(60); export const createTableSchema = z.object({ /** Omitted, the server derives one from the label and steps past collisions. */ name: tableNameSchema.optional(), label: z.string().min(1).max(60), /** What to call the row-label column; its identifier is derived. */ displayLabel: columnLabelSchema.optional(), /** The row-label column's identifier, when the API insists on one. */ display: columnNameSchema.optional(), /** The sidebar heading to list the new table under. */ group: z.string().trim().min(1).max(60).optional(), }); export const addColumnSchema = z .object({ /** Omitted, the label stands in — the server derives the identifier. */ name: columnNameSchema.optional(), label: columnLabelSchema.optional(), type: z.enum(COLUMN_TYPES), options: z.array(z.string().max(80)).max(100).optional(), target: tableNameSchema.optional(), expr: z.string().max(2000).optional(), format: formatSchema.optional(), required: z.boolean().optional(), unique: z.boolean().optional(), /** Numbering pattern; the engine validates its shape, this only its size. */ sequence: z.string().max(SEQUENCE_MAX_LENGTH).nullable().optional(), }) .strict() .refine((body) => body.name !== undefined || body.label !== undefined, { message: 'A column needs a name.', }); /** * A tightening: which constraints to put on a column that already holds data. * * Every field is optional and absent means "leave it as it is", so the same * body describes adding one constraint and changing four. `reading` is how the * text now in the column should be understood while it converts, and exists * because no value can answer either of its questions about itself. */ export const tightenSchema = z .object({ type: z.enum(STORED_COLUMN_TYPES).optional(), options: z.array(z.string().max(80)).max(100).optional(), target: tableNameSchema.optional(), required: z.boolean().optional(), unique: z.boolean().optional(), reading: z .object({ decimal: z.enum(['.', ',']).optional(), dayFirst: z.boolean().optional(), }) .strict() .optional(), onProblem: z.enum(['stop', 'clear']).optional(), }) .strict(); /** * Normalising: which spellings merge into which. `from` values are matched * exactly, whitespace and all — they came out of the plan, not out of a head. */ export const normalizeSchema = z .object({ merges: z .array( z .object({ to: z.string().min(1).max(1000), from: z.array(z.string().min(1).max(1000)).min(1).max(100), }) .strict(), ) .min(1) .max(100), }) .strict(); export const updateColumnSchema = z .object({ label: columnLabelSchema.optional(), options: z.array(z.string().max(80)).max(100).optional(), expr: z.string().max(2000).optional(), format: formatSchema.optional(), required: z.boolean().optional(), position: z.number().int().min(0).max(500).optional(), rename: columnNameSchema.optional(), sequence: z.string().max(SEQUENCE_MAX_LENGTH).nullable().optional(), }) .strict(); export const reorderColumnsSchema = z.object({ order: z.array(columnNameSchema).min(1).max(500), }); export const reorderTablesSchema = z.object({ order: z.array(tableNameSchema).min(1).max(500), }); /** Columns a view leaves out. Capped like the column count itself. */ export const hiddenSchema = z.array(columnNameSchema).max(500); export const upsertViewSchema = z.object({ name: z.string().min(1).max(60), filters: z.array(filterSchema).max(20).default([]), sort: sortSchema.nullable().default(null), summaries: summariesSchema.default({}), hidden: hiddenSchema.default([]), }); /** * Changing part of a saved view. * * Spelled out rather than `upsertViewSchema.partial()`, because `.partial()` * keeps the defaults above: a body of `{ name }` would come back out as * `{ name, filters: [], sort: null }` and quietly strip the view of everything * that made it a view. Here a field that was not sent stays `undefined`, and * `sort: null` keeps its own meaning — explicitly unsorted. */ export const patchViewSchema = z .object({ name: z.string().min(1).max(60).optional(), filters: z.array(filterSchema).max(20).optional(), sort: sortSchema.nullable().optional(), summaries: summariesSchema.optional(), hidden: hiddenSchema.optional(), }) .strict(); export const ruleSchema = z.object({ table: tableNameSchema, column: columnNameSchema, equals: z.string().max(200), label: z.string().min(1).max(120), enabled: z.boolean().default(true), actions: z .array( z.object({ type: z.literal('set'), column: columnNameSchema, value: z.string().max(200), }), ) .min(1) .max(10), }); export const loginSchema = z.object({ email: z.string().email().max(200), password: z.string().min(1).max(200), }); export const roleSchema = z.enum(['admin', 'editor', 'viewer']); export const slugSchema = z.string().refine(isValidWorkspaceSlug, { message: 'Invalid workspace address.', }); /** * What to build into a new workspace: nothing, the demo's structure, or the * demo with its data. Omitted means empty — most people arrive with the * spreadsheet they want to replace, and the first screen inside offers it. */ export const demoChoiceSchema = z.enum(['none', 'structure', 'full']).optional(); /** * A signup makes an account and nothing else. The workspace comes after, from * the picker — creating both in one breath meant a person choosing a workspace * name before they had ever seen one. */ export const signupSchema = z .object({ email: z.string().email().max(200), name: z.string().min(1).max(80), password: z.string().min(1).max(200), /** * The landing page this signup came from, quoted back from the browser. * * Optional in every sense: someone arriving by invite link, by a bookmark, * or with storage disabled has no impression to name, and a signup must * never depend on the measurement working. */ voice: z.string().max(64).optional(), }) .strict(); /** The impression id in `/api/public/voice/:id/seen`. Shaped like every other id. */ export const voiceIdParamSchema = z.object({ id: z.string().regex(/^[a-z0-9_]{1,64}$/) }).strict(); export const createWorkspaceSchema = z .object({ name: z.string().min(1).max(80), slug: slugSchema.optional(), demo: demoChoiceSchema, /** * Chosen on the creation screen; the Accept-Language header only answers * when the form did not ask. A workspace's language decides how its * numbers and dates are written, so it must be a choice the person can * see, not a guess they discover after their first import. */ locale: z.enum(SUPPORTED_LOCALES).optional(), }) .strict(); /** * Any ISO 4217 code, not only the ones the dialog lists. The list is a * convenience; refusing a currency because it did not make a dropdown would be * this product telling a business what money it earns. */ export const currencySchema = z .string() .refine(isCurrencyCode, { message: 'Use a three-letter currency code, such as EUR.' }) .transform((code) => code.toUpperCase()); export const updateWorkspaceSchema = z .object({ name: z.string().min(1).max(80).optional(), slug: slugSchema.optional(), currency: currencySchema.optional(), locale: z.enum(SUPPORTED_LOCALES).optional(), }) .strict(); export const createInviteSchema = z .object({ email: z.string().email().max(200), role: roleSchema, }) .strict(); export const acceptInviteSchema = z .object({ /** Both required when the invite's email has no account yet. */ name: z.string().min(1).max(80).optional(), password: z.string().min(1).max(200).optional(), }) .strict(); export const updateMemberSchema = z .object({ role: roleSchema.optional(), }) .strict(); export const updateAccountSchema = z .object({ name: z.string().min(1).max(80), }) .strict(); export const changePasswordSchema = z.object({ current: z.string().min(1).max(200), next: z.string().min(1).max(200), }); export const checkoutSchema = z.object({ plan: z.enum(PLAN_IDS), /** * Where the invoices should go. Optional only so that a workspace which has * already said keeps its answer; the route refuses when neither is present, * rather than falling back to whoever is clicking. */ billingEmail: z.string().email('That is not an email address.').max(200).optional(), }); export const billingContactSchema = z.object({ billingEmail: z.string().email('That is not an email address.').max(200), }); /** * Four megabytes of text, which at ~40 bytes a row is comfortably more than * the row cap the importer applies anyway. The import routes raise their own * body limit past the server's default to leave room for it. */ const csvSchema = z.string().min(1).max(IMPORT_TEXT_BYTES); /** * A file, as base64. * * A workbook is not text and cannot be pasted into a JSON string, and the rest * of this API is JSON. Base64 costs a third more bytes on the wire and buys * one transport for every request rather than a multipart exception for one of * them. */ const uploadSchema = z .object({ name: z.string().min(1).max(200), data: z.string().min(1).max(IMPORT_FILE_BASE64), }) .strict(); /** Which row names the columns; omitted means the parser works it out. */ const headerRowSchema = z.number().int().min(0).max(200_000).optional(); /** * Which row groups the columns. Omitted lets the parser decide; null says no * row does, whatever it thinks; a number names the row. */ const groupRowSchema = z.number().int().min(0).max(200_000).nullable().optional(); /** Which sheet of a workbook to read; omitted means the first with anything in it. */ const sheetSchema = z.string().min(1).max(200).optional(); /** * Corrections to the preview's guessed columns, addressed by position. A * correction is a label — any words at all — and the importer derives the * identifier from it the way the column dialog would. */ const importColumnsSchema = z .array( z .object({ index: z.number().int().min(0).max(999), label: z.string().min(1).max(60).optional(), type: z.enum(IMPORT_COLUMN_TYPES).optional(), }) .strict(), ) .max(60) .optional(); /** Text or a file, and exactly one of them — two sources would be two answers. */ /** * Corrections to the group headings, by position in the preview's `groups`. * An empty label is legal: it takes the heading off its whole group. */ const importGroupsSchema = z .array( z .object({ index: z.number().int().min(0).max(999), label: z.string().max(60), }) .strict(), ) .max(60) .optional(); const importSource = { csv: csvSchema.optional(), file: uploadSchema.optional(), headerRow: headerRowSchema, groupRow: groupRowSchema, /** Trailing totals rows to leave out; 0 keeps every row. Omitted, the parser decides. */ footerRows: z.number().int().min(0).max(1000).optional(), sheet: sheetSchema, columns: importColumnsSchema, groups: importGroupsSchema, }; const oneSource = (body: { csv?: string; file?: unknown }) => Boolean(body.csv) !== Boolean(body.file); export const previewImportSchema = z .object({ ...importSource, /** Preview against this table's columns, when appending into one. */ table: tableNameSchema.optional(), }) .strict() .refine(oneSource, 'Send either pasted text or a file, not both.'); export const importSchema = z .object({ ...importSource, /** Append into this table, or omit to create one. */ table: tableNameSchema.optional(), /** Only when creating: the new table's name and label. */ name: tableNameSchema.optional(), label: z.string().min(1).max(60).optional(), }) .strict() .refine(oneSource, 'Send either pasted text or a file, not both.'); export const forgotSchema = z.object({ email: z.string().email().max(200), }); export const resetPasswordSchema = z.object({ password: z.string().min(1).max(200), }); /** Reset and confirmation links carry a 32-byte base64url secret in the path. */ export const tokenParamSchema = z.object({ token: z.string().min(20).max(200), }); export const auditQuerySchema = z.object({ table: tableNameSchema.optional(), rowId: rowIdSchema.optional(), before: z.coerce.number().int().positive().optional(), limit: z.coerce.number().int().min(1).max(500).default(60), }); export const validateExprSchema = z.object({ table: tableNameSchema, expr: z.string().min(1).max(2000), });