/** * Record filter builder — maps property filters to a Prisma where-fragment * for `DatabaseRecord.properties` (relation `RecordProperty`, whose value * lives in the single JSONB column `value`, typed per property.type). * * Pure function: returns a plain object, unit-testable without a database. * The fragment is `AND`-combined — one `{ properties: { some: [...] } }` * clause per SQL-expressible filter (the standard Prisma pattern for * multiple `some`s). Filters SQL cannot express (envelope values, ranges, * contains) are validated here but left to the service's exact JS pass. * * Value shapes passed as filter values (per property type — the docs §5.3 * envelopes are unwrapped by the service before this is called): * text-ish (TITLE TEXT RICH_TEXT EMAIL PHONE URL UNIQUE_ID) → string * NUMBER → number SELECT STATUS → string (option name) * MULTI_SELECT PERSON FILES RELATION → string[] * DATE → ISO string DATE_RANGE → {start, end} * CHECKBOX → boolean */ import { ApiError } from "../common/errors.js"; import { FILTER_OPS } from "../constants.js"; export interface PropertyFilter { propertyId: string; op: string; value?: unknown; } export type PropertyType = string; const DATE_TYPES = new Set(["DATE", "DATE_RANGE"]); const NUMBER_TYPES = new Set(["NUMBER"]); const TEXT_TYPES = new Set(["TITLE", "TEXT", "RICH_TEXT", "EMAIL", "PHONE", "URL", "UNIQUE_ID", "SELECT", "STATUS"]); /** * Ops evaluated in JS only (query.service.ts `leafMatches`): they need row * context (option resolution, the current user) or are array-membership * tests Prisma cannot express on the JSONB column. buildRecordFilter * validates them but emits no SQL clause for them. */ export const JS_ONLY_OPS = new Set(["contains_any", "contains_all", "isCurrentUser"]); interface ValueCondition { [key: string]: unknown; } /** * Build the value-condition for one (op, value, type) triple. * Exported for unit tests. */ export function buildValueCondition(op: string, value: unknown, type: PropertyType): ValueCondition { switch (op) { case "eq": if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" && !Array.isArray(value)) { throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a scalar value`, [{ field: "filter.value", issue: "scalar required" }]); } return { equals: value }; case "neq": if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" && !Array.isArray(value)) { throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a scalar value`, [{ field: "filter.value", issue: "scalar required" }]); } // exclude rows where value equals the given one OR is null return { AND: [{ not: { equals: value } }, { not: { equals: null } }] }; case "contains": assertText(type, op); return { string_contains: String(value) }; case "ncontains": assertText(type, op); return { AND: [{ not: { string_contains: String(value) } }, { not: { equals: null } }] }; case "startsWith": assertText(type, op); return { string_starts_with: String(value) }; case "endsWith": assertText(type, op); return { string_ends_with: String(value) }; case "isEmpty": return { equals: null }; case "isNotEmpty": return { not: { equals: null } }; case "contains_any": case "contains_all": { // Validated only (JS pass evaluates membership). The web sends an // {optionIds:[…]} envelope (simplifyValue does not unwrap it), so // unwrap id envelopes before the array check. Empty arrays are // rejected as a no-op filter. const ids = unwrapIdArray(value); if (ids.length === 0) { throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a non-empty array value`, [ { field: "filter.value", issue: "array required" } ]); } return { equals: ids }; } case "isCurrentUser": // Evaluated against the requesting user in the JS pass. return { equals: true }; case "isThisWeek": case "isThisMonth": case "isPast": case "isFuture": // Date-window ops evaluated in JS (needs "now"); validated via the // date types only. if (!DATE_TYPES.has(type)) { throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a date property`, [ { field: "filter.property", issue: `property type ${type} is not date` } ]); } return { equals: true }; case "withinNextDays": { // The web sends a {days: N} envelope (simplifyValue does not unwrap // `days`); accept a bare number/string for the docs' shape too. const nRaw = value !== null && typeof value === "object" && !Array.isArray(value) ? (value as { days?: unknown }).days : value; const n = typeof nRaw === "string" ? Number(nRaw) : nRaw; if (typeof n !== "number" || Number.isNaN(n) || n <= 0) { throw new ApiError("VALIDATION_ERROR", 400, "operator withinNextDays requires a positive number of days", [ { field: "filter.value", issue: "positive number required" } ]); } if (!DATE_TYPES.has(type)) { throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a date property`, [ { field: "filter.property", issue: `property type ${type} is not date` } ]); } return { equals: n }; } case "between": { if (!DATE_TYPES.has(type)) { throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a date property`, [ { field: "filter.property", issue: `property type ${type} is not date` } ]); } // The web's default between is {start:"",end:""} — treat empty bounds // as unbounded (rather than 400) so the default filter stays valid. const range = value as { start?: unknown; end?: unknown }; if (range?.start != null && range?.start !== "") toIso(String(range.start)); if (range?.end != null && range?.end !== "") toIso(String(range.end)); return { equals: value }; } case "gte": case "lte": case "gt": case "lt": case "after": case "before": { const opMap: Record = { gte: "gte", lte: "lte", gt: "gt", lt: "lt", after: "gte", before: "lte" }; const key = opMap[op]!; if (DATE_TYPES.has(type)) { if (type === "DATE_RANGE") { // range matches when it overlaps: start <= value(after) and end >= value(before) if (op === "after") return { gte: toIso(value) }; if (op === "before") return { lte: toIso(value) }; return { [key]: toIso(value) }; } return { [key]: toIso(value) }; } const n = typeof value === "string" ? Number(value) : value; if (typeof n !== "number" || Number.isNaN(n)) { throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a number or ISO date`, [{ field: "filter.value", issue: "number/date required" }]); } return { [key]: n }; } default: throw new ApiError("VALIDATION_ERROR", 400, `unknown filter operator: ${op}`, [ { field: "filter.op", issue: "unknown op", received: op } ]); } } /** * Unwrap a filter value into a string[] of ids. Accepts a bare array, an * id-envelope object ({optionIds}/{userIds}/{recordIds}/{fileIds}/{ids}), * or a single id string. Returns [] when nothing usable is present. */ function unwrapIdArray(value: unknown): string[] { if (Array.isArray(value)) return value.map((v) => String(v)); if (value !== null && typeof value === "object") { for (const k of ["optionIds", "userIds", "recordIds", "fileIds", "ids"]) { const v = (value as Record)[k]; if (Array.isArray(v)) return v.map((x) => String(x)); } } if (typeof value === "string" && value !== "") return [value]; return []; } function assertText(type: string, op: string): void { if (!TEXT_TYPES.has(type)) { throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a text property`, [ { field: "filter.property", issue: `property type ${type} is not text` } ]); } } function toIso(value: unknown): string { if (value instanceof Date) return value.toISOString(); if (typeof value !== "string") { throw new ApiError("VALIDATION_ERROR", 400, "date filter requires an ISO date string", [{ field: "filter.value", issue: "iso date required" }]); } const d = new Date(value); if (Number.isNaN(d.getTime())) { throw new ApiError("VALIDATION_ERROR", 400, "invalid date value", [{ field: "filter.value", issue: "invalid date", received: value }]); } return d.toISOString(); } /** * Types whose `RecordProperty.value` is stored as a **raw scalar** (not a * JSON envelope like `{ text }` / `{ value }` / `{ optionId }` — see * `coerceValue`). Only these can be pre-filtered in SQL: Prisma's JSONB * filter without a `path` only supports whole-value `equals`/`not`, and a * whole-envelope `equals` would never match the raw filter value. * * Every other (op, type) combination is still VALIDATED here (unknown ops, * `contains` on a non-text type, bad dates → 400 with a message), but it * produces no SQL clause: the query service re-checks the whole filter tree * in JS over the bounded row set (`applyDisjunction`), which is the exact * evaluator — the SQL fragment is only a pre-filter, so skipping it changes * no results, it only fetches a bit more for the JS pass (≤ 5000 rows, the * documented bound). * * Historically every op was pushed into a top-level JSONB condition * (`string_contains`, `gte`, … directly on the `value` column), which is * not a valid `RecordPropertyWhereInput` and made Prisma throw * `PrismaClientValidationError` → 400 "Invalid database query." for any * filtered list (search-in-view, quick filters, saved view filters). */ const RAW_SCALAR_TYPES = new Set(["CHECKBOX"]); /** * Build the DatabaseRecord where-fragment for a list of property filters. * * `propertyTypes` maps propertyId → PropertyType (used to pick the value * comparison semantics). Returns `{ AND: [...] }`; an empty input (or one * with nothing SQL-expressible) returns an empty fragment `{}`. */ export function buildRecordFilter( propertyFilters: PropertyFilter[], propertyTypes: Record, _prisma?: unknown ): Record { if (propertyFilters.length === 0) return {}; const clauses: Record[] = []; for (const f of propertyFilters) { if (!FILTER_OPS.includes(f.op as (typeof FILTER_OPS)[number])) { throw new ApiError("VALIDATION_ERROR", 400, `unknown filter operator: ${f.op}`, [ { field: "filter.op", issue: "unknown op", received: f.op } ]); } const type = propertyTypes[f.propertyId] ?? "TEXT"; // Validate the (op, value, type) triple first so malformed filters still // 400 with a message — then only emit a clause when SQL can express it. const cond = buildValueCondition(f.op, f.value, type); if (!RAW_SCALAR_TYPES.has(type) || (f.op !== "eq" && f.op !== "neq")) continue; // The JSONB column is compared whole — nest the condition under `value`. clauses.push({ properties: { some: [{ propertyId: f.propertyId, value: cond }] } }); } return clauses.length === 1 ? clauses[0]! : clauses.length > 1 ? { AND: clauses } : {}; }