/** * page-spec-filters.ts — canonical derivation of the SERVER filter params a * list pagespec's `filters[]` exposes on the screen stratum's `GET /list`. * * INVARIANT (the sibling of `page-spec-related-tabs.fkFilterFields`): * camelCase wire query param === Get{E}ListScreenQuery member (PascalCase) * === api-client list-params member — all three derive from THIS function, * in THIS order, so the controller's positional/named construction, the * query record and the frontend params always line up. * * Derivation rules (deterministic): * - a GLOBAL-SEARCH text filter (`field` matching q/search/recherche/fulltext * with a text control) is FUSED with the existing `search` param — skipped; * - a `lookup` filter rides the EXISTING `Guid?` FK channel (`fkFilterFields` * → `spec.fkFilters`) — skipped here ONLY when the field really is in that * channel, as is any filter whose field is already an FK filter param * (e.g. a `select` authored on a Guid FK field). A `lookup` filter whose * field is NOT an FK param used to be skipped unconditionally, on the * assumption the channel covered it: it covered nothing, so the filter got * no wire param at all while the page kept posting it (dead filter). Such a * filter now gets an explicit `string?` EQUALS param — it matches an id, so * it is never a CONTAINS predicate; * - `date-range` → TWO `DateTime?` params `{field}From` / `{field}To` * (camelCase wire names match the generated page's filter state keys); * - `boolean` → `bool?`; * - `select` → `string?` with an EQUALS predicate; * - `text` (and any unknown control) → `string?` with a CONTAINS predicate. * * The predicate `kind` is guidance for the HAND-WRITTEN GetForListScreenAsync * body (Phase 2b): a param on a STORED column maps to a `Where` on it; a param * on a COMPUTED/derived column (settlementStatus, origin…) must implement the * derivation — the scaffolders only carry the param through the wire. */ export interface PageSpecFilterDecl { /** camelCase filter field from the pagespec `filters[]`. */ field: string /** Filter control (text | select | lookup | boolean | date-range). */ control?: string /** Resolved FK target of a reference filter (same block as a form field's). * Its presence makes the filter an ID match — an EQUALS predicate — even * when the PRD declared `control: "text"`, which would otherwise have * emitted a CONTAINS predicate running `LIKE '%%'` on a Guid column. */ fkTo?: unknown [key: string]: unknown } export interface ScreenFilterParam { /** camelCase wire name — the `[FromQuery]` param AND the api-client member. */ name: string /** PascalCase query-record member (`query.{Pascal}` in the service body). */ pascal: string /** C# parameter type on the controller and the query record. */ csType: 'string?' | 'bool?' | 'DateTime?' /** TS member type on the frontend list params (dates travel as ISO strings). */ tsType: 'string' | 'boolean' /** The pagespec filter field this param serves (date-range: without From/To). */ field: string /** Predicate shape the service body implements for this param. */ kind: 'equals' | 'contains' | 'boolean' | 'date-from' | 'date-to' } /** A text filter on one of these fields IS the global search box. */ export const GLOBAL_SEARCH_FILTER_RE = /^(q|search|recherche|fulltext)$/i function pascalOf(camel: string): string { return camel.length > 0 ? camel.charAt(0).toUpperCase() + camel.slice(1) : camel } function isDateRangeControl(control: string): boolean { return control === 'date-range' || control === 'daterange' } /** * Ordered server filter params for a list pagespec. `fkFilters` is the * camelCase Guid FK param list already exposed by the FK channel * (`fkFilterFields` / `spec.fkFilters`) — matching fields are skipped so a * param is never declared twice. */ export function screenFilterParams( filters: readonly PageSpecFilterDecl[] | undefined, fkFilters: readonly string[] = [], ): ScreenFilterParam[] { const out: ScreenFilterParam[] = [] const fk = new Set(fkFilters) for (const f of filters ?? []) { const control = (f.control ?? 'text').toLowerCase() if (GLOBAL_SEARCH_FILTER_RE.test(f.field) && control === 'text') continue if (fk.has(f.field)) continue // Reference filter OUTSIDE the Guid FK channel: it still needs a wire // param, and it matches an id — EQUALS, never CONTAINS. if (control === 'lookup' || f.fkTo != null) { out.push({ name: f.field, pascal: pascalOf(f.field), csType: 'string?', tsType: 'string', field: f.field, kind: 'equals' }) continue } if (isDateRangeControl(control)) { out.push({ name: `${f.field}From`, pascal: pascalOf(`${f.field}From`), csType: 'DateTime?', tsType: 'string', field: f.field, kind: 'date-from' }) out.push({ name: `${f.field}To`, pascal: pascalOf(`${f.field}To`), csType: 'DateTime?', tsType: 'string', field: f.field, kind: 'date-to' }) continue } if (control === 'boolean') { out.push({ name: f.field, pascal: pascalOf(f.field), csType: 'bool?', tsType: 'boolean', field: f.field, kind: 'boolean' }) continue } if (control === 'select') { out.push({ name: f.field, pascal: pascalOf(f.field), csType: 'string?', tsType: 'string', field: f.field, kind: 'equals' }) continue } out.push({ name: f.field, pascal: pascalOf(f.field), csType: 'string?', tsType: 'string', field: f.field, kind: 'contains' }) } return out }