1
20
21import { ApiError } from "../common/errors.js";
22import { FILTER_OPS } from "../constants.js";
23
24export interface PropertyFilter {
25 propertyId: string;
26 op: string;
27 value?: unknown;
28}
29
30export type PropertyType = string;
31
32const DATE_TYPES = new Set(["DATE", "DATE_RANGE"]);
33const NUMBER_TYPES = new Set(["NUMBER"]);
34const TEXT_TYPES = new Set(["TITLE", "TEXT", "RICH_TEXT", "EMAIL", "PHONE", "URL", "UNIQUE_ID", "SELECT", "STATUS"]);
35
36
42export const JS_ONLY_OPS = new Set(["contains_any", "contains_all", "isCurrentUser"]);
43
44interface ValueCondition {
45 [key: string]: unknown;
46}
47
48
52export function buildValueCondition(op: string, value: unknown, type: PropertyType): ValueCondition {
53 switch (op) {
54 case "eq":
55 if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" && !Array.isArray(value)) {
56 throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a scalar value`, [{ field: "filter.value", issue: "scalar required" }]);
57 }
58 return { equals: value };
59 case "neq":
60 if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" && !Array.isArray(value)) {
61 throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a scalar value`, [{ field: "filter.value", issue: "scalar required" }]);
62 }
63
64 return { AND: [{ not: { equals: value } }, { not: { equals: null } }] };
65 case "contains":
66 assertText(type, op);
67 return { string_contains: String(value) };
68 case "ncontains":
69 assertText(type, op);
70 return { AND: [{ not: { string_contains: String(value) } }, { not: { equals: null } }] };
71 case "startsWith":
72 assertText(type, op);
73 return { string_starts_with: String(value) };
74 case "endsWith":
75 assertText(type, op);
76 return { string_ends_with: String(value) };
77 case "isEmpty":
78 return { equals: null };
79 case "isNotEmpty":
80 return { not: { equals: null } };
81 case "contains_any":
82 case "contains_all": {
83
84
85
86
87 const ids = unwrapIdArray(value);
88 if (ids.length === 0) {
89 throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a non-empty array value`, [
90 { field: "filter.value", issue: "array required" }
91 ]);
92 }
93 return { equals: ids };
94 }
95 case "isCurrentUser":
96
97 return { equals: true };
98 case "isThisWeek":
99 case "isThisMonth":
100 case "isPast":
101 case "isFuture":
102
103
104 if (!DATE_TYPES.has(type)) {
105 throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a date property`, [
106 { field: "filter.property", issue: `property type ${type} is not date` }
107 ]);
108 }
109 return { equals: true };
110 case "withinNextDays": {
111
112
113 const nRaw = value !== null && typeof value === "object" && !Array.isArray(value)
114 ? (value as { days?: unknown }).days
115 : value;
116 const n = typeof nRaw === "string" ? Number(nRaw) : nRaw;
117 if (typeof n !== "number" || Number.isNaN(n) || n <= 0) {
118 throw new ApiError("VALIDATION_ERROR", 400, "operator withinNextDays requires a positive number of days", [
119 { field: "filter.value", issue: "positive number required" }
120 ]);
121 }
122 if (!DATE_TYPES.has(type)) {
123 throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a date property`, [
124 { field: "filter.property", issue: `property type ${type} is not date` }
125 ]);
126 }
127 return { equals: n };
128 }
129 case "between": {
130 if (!DATE_TYPES.has(type)) {
131 throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a date property`, [
132 { field: "filter.property", issue: `property type ${type} is not date` }
133 ]);
134 }
135
136
137 const range = value as { start?: unknown; end?: unknown };
138 if (range?.start != null && range?.start !== "") toIso(String(range.start));
139 if (range?.end != null && range?.end !== "") toIso(String(range.end));
140 return { equals: value };
141 }
142 case "gte":
143 case "lte":
144 case "gt":
145 case "lt":
146 case "after":
147 case "before": {
148 const opMap: Record<string, string> = {
149 gte: "gte", lte: "lte", gt: "gt", lt: "lt", after: "gte", before: "lte"
150 };
151 const key = opMap[op]!;
152 if (DATE_TYPES.has(type)) {
153 if (type === "DATE_RANGE") {
154
155 if (op === "after") return { gte: toIso(value) };
156 if (op === "before") return { lte: toIso(value) };
157 return { [key]: toIso(value) };
158 }
159 return { [key]: toIso(value) };
160 }
161 const n = typeof value === "string" ? Number(value) : value;
162 if (typeof n !== "number" || Number.isNaN(n)) {
163 throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a number or ISO date`, [{ field: "filter.value", issue: "number/date required" }]);
164 }
165 return { [key]: n };
166 }
167 default:
168 throw new ApiError("VALIDATION_ERROR", 400, `unknown filter operator: ${op}`, [
169 { field: "filter.op", issue: "unknown op", received: op }
170 ]);
171 }
172}
173
174
179function unwrapIdArray(value: unknown): string[] {
180 if (Array.isArray(value)) return value.map((v) => String(v));
181 if (value !== null && typeof value === "object") {
182 for (const k of ["optionIds", "userIds", "recordIds", "fileIds", "ids"]) {
183 const v = (value as Record<string, unknown>)[k];
184 if (Array.isArray(v)) return v.map((x) => String(x));
185 }
186 }
187 if (typeof value === "string" && value !== "") return [value];
188 return [];
189}
190
191function assertText(type: string, op: string): void {
192 if (!TEXT_TYPES.has(type)) {
193 throw new ApiError("VALIDATION_ERROR", 400, `operator ${op} requires a text property`, [
194 { field: "filter.property", issue: `property type ${type} is not text` }
195 ]);
196 }
197}
198
199function toIso(value: unknown): string {
200 if (value instanceof Date) return value.toISOString();
201 if (typeof value !== "string") {
202 throw new ApiError("VALIDATION_ERROR", 400, "date filter requires an ISO date string", [{ field: "filter.value", issue: "iso date required" }]);
203 }
204 const d = new Date(value);
205 if (Number.isNaN(d.getTime())) {
206 throw new ApiError("VALIDATION_ERROR", 400, "invalid date value", [{ field: "filter.value", issue: "invalid date", received: value }]);
207 }
208 return d.toISOString();
209}
210
211
232const RAW_SCALAR_TYPES = new Set(["CHECKBOX"]);
233
234
241export function buildRecordFilter(
242 propertyFilters: PropertyFilter[],
243 propertyTypes: Record<string, PropertyType>,
244 _prisma?: unknown
245): Record<string, unknown> {
246 if (propertyFilters.length === 0) return {};
247 const clauses: Record<string, unknown>[] = [];
248 for (const f of propertyFilters) {
249 if (!FILTER_OPS.includes(f.op as (typeof FILTER_OPS)[number])) {
250 throw new ApiError("VALIDATION_ERROR", 400, `unknown filter operator: ${f.op}`, [
251 { field: "filter.op", issue: "unknown op", received: f.op }
252 ]);
253 }
254 const type = propertyTypes[f.propertyId] ?? "TEXT";
255
256
257 const cond = buildValueCondition(f.op, f.value, type);
258 if (!RAW_SCALAR_TYPES.has(type) || (f.op !== "eq" && f.op !== "neq")) continue;
259
260 clauses.push({ properties: { some: [{ propertyId: f.propertyId, value: cond }] } });
261 }
262 return clauses.length === 1 ? clauses[0]! : clauses.length > 1 ? { AND: clauses } : {};
263}
264