1
28import type { PrismaClient } from "@prisma/client";
29import {
30 normalizeConditionValue,
31 validateConditionTree
32} from "@factory/shared";
33
34
35export class ConditionError extends Error {
36 constructor(
37 public readonly code: "CONDITION_INVALID" | "CONDITION_TIMEOUT",
38 message: string
39 ) {
40 super(message);
41 this.name = "ConditionError";
42 }
43}
44
45
46export const CONDITION_EVAL_BUDGET_MS = 50;
47
48
54export interface ConditionContext {
55 event: Record<string, unknown>;
56 record: Record<string, unknown> | null;
57 page: Record<string, unknown> | null;
58}
59
60export interface ConditionDecision {
61 result: boolean;
62 evaluatedNodes: number;
63 ms: number;
64 warnings: string[];
65}
66
67
68
69
70
71
76export function unwrapEnvelope(type: string, v: unknown): unknown {
77 if (v === null || v === undefined) return null;
78 if (typeof v !== "object" || Array.isArray(v)) return v;
79 const o = v as Record<string, unknown>;
80 switch (type) {
81 case "TEXT":
82 case "RICH_TEXT":
83 case "TITLE":
84 return typeof o.text === "string" ? o.text : v;
85 case "NUMBER":
86 case "EMAIL":
87 case "PHONE":
88 case "CHECKBOX":
89 return "value" in o ? o.value : v;
90 case "SELECT":
91 case "STATUS":
92 return typeof o.optionId === "string" ? o.optionId : v;
93 case "MULTI_SELECT":
94 return Array.isArray(o.optionIds) ? o.optionIds : v;
95 case "PERSON":
96 return Array.isArray(o.userIds) ? o.userIds : v;
97 case "DATE":
98 case "DATE_RANGE":
99 if (typeof o.start === "string") return o.end ? `${o.start}..${o.end}` : o.start;
100 return v;
101 case "URL":
102 return typeof o.value === "string" ? o.value : v;
103 case "FILES":
104 return Array.isArray(o.fileIds) ? o.fileIds : v;
105 default:
106 return v;
107 }
108}
109
110
115export async function buildConditionContext(
116 prisma: PrismaClient,
117 triggerEvent: Record<string, unknown>,
118 recordId?: string | null
119): Promise<ConditionContext> {
120 let record: Record<string, unknown> | null = null;
121 let page: Record<string, unknown> | null = null;
122
123 if (recordId) {
124 const row = await prisma.databaseRecord.findUnique({
125 where: { id: recordId },
126 select: {
127 id: true,
128 page: { select: { id: true, title: true, workspaceId: true } },
129 properties: {
130 select: {
131 value: true,
132 property: { select: { name: true, type: true, config: true, isTitle: true } }
133 }
134 }
135 }
136 });
137 if (row) {
138 const flat: Record<string, unknown> = { id: row.id };
139 for (const rp of row.properties) {
140 const unwrapped = unwrapEnvelope(rp.property.type as string, rp.value);
141 flat[rp.property.name] = normalizeConditionValue(
142 rp.property.type as string,
143 rp.property.config as Record<string, unknown> | null,
144 unwrapped
145 );
146 }
147 record = flat;
148 if (row.page) page = row.page;
149 }
150 } else if (typeof triggerEvent.pageId === "string") {
151 const p = await prisma.page.findUnique({
152 where: { id: triggerEvent.pageId },
153 select: { id: true, title: true, workspaceId: true }
154 });
155 if (p) page = p;
156 }
157
158 return { event: triggerEvent, record, page };
159}
160
161
162
163
164
165const isArr = (v: unknown): v is unknown[] => Array.isArray(v);
166
167function toNum(v: unknown): number | null {
168 if (typeof v === "number" && Number.isFinite(v)) return v;
169 if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) return Number(v);
170 return null;
171}
172
173function toTs(v: unknown): number | null {
174 if (typeof v === "string" && v.includes("..") === false) {
175 const t = Date.parse(v);
176 if (!Number.isNaN(t)) return t;
177 }
178 if (v instanceof Date) return v.getTime();
179 return null;
180}
181
182function setEq(a: unknown[], b: unknown[]): boolean {
183 if (a.length !== b.length) return false;
184 const key = (x: unknown) => (typeof x === "object" ? JSON.stringify(x) : String(x));
185 const as = a.map(key).sort();
186 const bs = b.map(key).sort();
187 return as.every((v, i) => v === bs[i]);
188}
189
190
191function normEq(a: unknown, b: unknown): boolean {
192 const aN = a === undefined ? null : a;
193 const bN = b === undefined ? null : b;
194 if (aN === null && bN === null) return true;
195 if (aN === null || bN === null) return false;
196 if (isArr(aN) && isArr(bN)) return setEq(aN, bN);
197 if (isArr(aN) !== isArr(bN)) return false;
198 if (typeof aN === "number" || typeof bN === "number") {
199 const an = toNum(aN);
200 const bn = toNum(bN);
201 return an !== null && bn !== null && an === bn;
202 }
203 if (typeof aN === "boolean" || typeof bN === "boolean") return aN === bN;
204 if (typeof aN === "string" && typeof bN === "string") return aN === bN;
205 if (typeof aN === "object" && typeof bN === "object")
206 return JSON.stringify(aN) === JSON.stringify(bN);
207 return false;
208}
209
210
211function cmp(a: unknown, b: unknown): number | null {
212 const da = toTs(a);
213 const db = toTs(b);
214 if (da !== null && db !== null) return da - db;
215 const an = toNum(a);
216 const bn = toNum(b);
217 if (an !== null && bn !== null) return an - bn;
218 return null;
219}
220
221function isEmptyValue(v: unknown): boolean {
222 if (v === undefined || v === null) return true;
223 if (typeof v === "string") return v.length === 0;
224 if (Array.isArray(v)) return v.length === 0;
225 if (typeof v === "object") return Object.keys(v as object).length === 0;
226 return false;
227}
228
229function ciStartsWith(actual: string, value: string): boolean {
230 return actual.toLowerCase().startsWith(value.toLowerCase());
231}
232function ciEndsWith(actual: string, value: string): boolean {
233 return actual.toLowerCase().endsWith(value.toLowerCase());
234}
235function ciIncludes(actual: string, value: string): boolean {
236 return actual.toLowerCase().includes(value.toLowerCase());
237}
238
239
240
241
242
243
244function resolveDot(obj: unknown, path: string): unknown {
245 let cur: unknown = obj;
246 for (const part of path.split(".").slice(0, 3)) {
247 if (cur === null || cur === undefined || typeof cur !== "object") return undefined;
248 cur = (cur as Record<string, unknown>)[part];
249 }
250 return cur;
251}
252
253function readField(
254 field: { ref: string; name: string },
255 ctx: ConditionContext,
256 warnings: string[]
257): { value: unknown; present: boolean } {
258 if (field.ref === "event") {
259 const v = resolveDot(ctx.event, field.name);
260 return { value: v, present: v !== undefined };
261 }
262
263 if (!ctx.record) {
264 warnings.push(`property "${field.name}": no record available (fail closed)`);
265 return { value: undefined, present: false };
266 }
267 if (!(field.name in ctx.record)) {
268 warnings.push(`property "${field.name}": unknown property (fail closed)`);
269 return { value: undefined, present: false };
270 }
271 const v = ctx.record[field.name];
272 return { value: v, present: v !== undefined };
273}
274
275
276
277
278
279interface PredicateNode {
280 field: { ref: string; name: string };
281 operator: string;
282 value?: unknown;
283}
284
285function isPredicate(node: Record<string, unknown> | PredicateNode): node is PredicateNode {
286 const f = node.field;
287 return (
288 typeof f === "object" &&
289 f !== null &&
290 (f as { ref?: unknown }).ref !== undefined &&
291 typeof node.operator === "string"
292 );
293}
294
295function evalPredicate(pred: PredicateNode, ctx: ConditionContext, warnings: string[]): boolean {
296 const { value: actual, present } = readField(pred.field, ctx, warnings);
297 const op = pred.operator;
298
299
300 if (op === "is_empty") return isEmptyValue(actual);
301 if (op === "is_not_empty") return !isEmptyValue(actual);
302
303
304 if (!present || actual === undefined) {
305 warnings.push(`predicate ${pred.field.ref}.${pred.field.name} ${op}: field absent (fail closed)`);
306 return false;
307 }
308
309 const v = pred.value;
310 switch (op) {
311 case "eq":
312 return normEq(actual, v);
313 case "neq":
314 return !normEq(actual, v);
315 case "contains":
316 if (typeof actual === "string" && typeof v === "string") return ciIncludes(actual, v);
317 if (isArr(actual)) return (actual as unknown[]).some((x) => normEq(x, v));
318 warnings.push(`contains on non-string/non-array actual`);
319 return false;
320 case "not_contains":
321 if (typeof actual === "string" && typeof v === "string") return !ciIncludes(actual, v);
322 if (isArr(actual)) return !(actual as unknown[]).some((x) => normEq(x, v));
323 warnings.push(`not_contains on non-string/non-array actual`);
324 return false;
325 case "starts_with":
326 if (typeof actual === "string" && typeof v === "string") return ciStartsWith(actual, v);
327 warnings.push(`starts_with on non-string actual`);
328 return false;
329 case "ends_with":
330 if (typeof actual === "string" && typeof v === "string") return ciEndsWith(actual, v);
331 warnings.push(`ends_with on non-string actual`);
332 return false;
333 case "in": {
334 const list = isArr(v) ? (v as unknown[]) : [v];
335 if (isArr(actual)) return (actual as unknown[]).some((x) => list.some((y) => normEq(x, y)));
336 return list.some((y) => normEq(actual, y));
337 }
338 case "not_in": {
339 const list = isArr(v) ? (v as unknown[]) : [v];
340 if (isArr(actual)) return !(actual as unknown[]).some((x) => list.some((y) => normEq(x, y)));
341 return !list.some((y) => normEq(actual, y));
342 }
343 case "gt":
344 case "gte":
345 case "lt":
346 case "lte": {
347 const c = cmp(actual, v);
348 if (c === null) {
349 warnings.push(`${op}: incomparable ${String(typeof actual)} vs ${String(typeof v)} (fail closed)`);
350 return false;
351 }
352 if (op === "gt") return c > 0;
353 if (op === "gte") return c >= 0;
354 if (op === "lt") return c < 0;
355 return c <= 0;
356 }
357 default:
358 warnings.push(`unknown operator ${op}`);
359 return false;
360 }
361}
362
363interface EvalState {
364 nodes: number;
365 warnings: string[];
366}
367
368function walk(node: unknown, ctx: ConditionContext, st: EvalState): boolean {
369 st.nodes += 1;
370 if (node === null || typeof node !== "object") {
371 st.warnings.push("malformed condition node (not an object)");
372 return false;
373 }
374 const n = node as Record<string, unknown>;
375
376 if (n.op === "AND" || n.op === "OR" || n.op === "NOT") {
377 if (n.op === "NOT") {
378 return !walk(n.child, ctx, st);
379 }
380 const children = Array.isArray(n.children) ? (n.children as unknown[]) : [];
381 if (n.op === "AND") return children.every((c) => walk(c, ctx, st));
382 return children.length > 0 && children.some((c) => walk(c, ctx, st));
383 }
384
385 if (isPredicate(n)) return evalPredicate(n, ctx, st.warnings);
386
387 st.warnings.push(`unrecognized condition node`);
388 return false;
389}
390
391
398export function evaluateCondition(
399 tree: unknown,
400 ctx: ConditionContext,
401 now: () => number = () => Date.now()
402): ConditionDecision {
403 if (tree === null || tree === undefined) {
404 return { result: true, evaluatedNodes: 0, ms: 0, warnings: [] };
405 }
406
407 const check = validateConditionTree(tree);
408 if (!check.ok) {
409 throw new ConditionError(
410 "CONDITION_INVALID",
411 check.message ?? `corrupt condition tree (${check.code})`
412 );
413 }
414
415 const t0 = now();
416 const st: EvalState = { nodes: 0, warnings: [] };
417 const result = walk(tree, ctx, st);
418 const ms = Math.max(0, now() - t0);
419 if (ms > CONDITION_EVAL_BUDGET_MS) {
420 throw new ConditionError(
421 "CONDITION_TIMEOUT",
422 `condition evaluation exceeded ${CONDITION_EVAL_BUDGET_MS} ms (${ms} ms, ${st.nodes} nodes)`
423 );
424 }
425 return { result, evaluatedNodes: st.nodes, ms, warnings: st.warnings };
426}
427