Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

15.6 kB Ruw bestand
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),
});