// Rules management view — visual, non-technical rule builder for alerts.
const { useState: useRuleState, useMemo: useRuleMemo, useEffect: useRuleEffect } = React;
const RULE_TEMPLATES = {
threshold: {
name: "",
description: "",
rule_type: "threshold",
severity: "medium",
priority: 5,
condition: { operator: "gt", field: "amount", value: 10000 },
actions: [{ action: "create_alert", params: {} }],
lookback_seconds: 86400,
aggregation_key: null,
tags: [],
},
list_match: {
name: "",
description: "",
rule_type: "list_match",
severity: "high",
priority: 4,
condition: { operator: "in", field: "originator_country", value: ["XX", "YY"] },
actions: [{ action: "create_alert", params: {} }],
lookback_seconds: 86400,
aggregation_key: null,
tags: [],
},
velocity: {
name: "",
description: "",
rule_type: "velocity",
severity: "high",
priority: 3,
condition: { operator: "gt", field: "amount", value: 50000 },
actions: [{ action: "create_alert", params: {} }],
lookback_seconds: 86400,
aggregation_key: "originator_id",
tags: [],
},
anomaly: {
name: "",
description: "",
rule_type: "anomaly",
severity: "high",
priority: 3,
condition: { operator: "gt", field: "risk_score", value: 75 },
actions: [{ action: "create_alert", params: {} }],
lookback_seconds: 86400,
aggregation_key: null,
tags: [],
},
composite: {
name: "",
description: "",
rule_type: "composite",
severity: "critical",
priority: 1,
condition: {
operator: "and",
conditions: [
{ operator: "gt", field: "amount", value: 50000 },
{ operator: "in", field: "originator_country", value: ["XX", "YY"] },
],
},
actions: [{ action: "create_alert", params: {} }],
lookback_seconds: 86400,
aggregation_key: null,
tags: [],
},
};
const TRANSACTION_TYPES = ["wire", "ach", "card", "atm", "crypto", "cash"];
const FIELD_OPTIONS = [
{ value: "amount", label: "Amount", type: "number", placeholder: "e.g. 10000" },
{ value: "risk_score", label: "Risk score", type: "number", placeholder: "0-100" },
{ value: "transaction_type", label: "Transaction type", type: "txn_type", placeholder: "e.g. card" },
{ value: "originator_country", label: "Originator country", type: "country", placeholder: "e.g. US" },
{ value: "beneficiary_country", label: "Beneficiary country", type: "country", placeholder: "e.g. US" },
{ value: "originator_id", label: "Originator ID", type: "text", placeholder: "e.g. C123456" },
{ value: "beneficiary_id", label: "Beneficiary ID", type: "text", placeholder: "e.g. M123456" },
];
const OPERATORS_BY_TYPE = {
number: [
{ value: "gt", label: "is greater than" },
{ value: "gte", label: "is greater than or equal to" },
{ value: "lt", label: "is less than" },
{ value: "lte", label: "is less than or equal to" },
{ value: "eq", label: "equals" },
{ value: "ne", label: "does not equal" },
{ value: "between", label: "is between" },
],
text: [
{ value: "eq", label: "equals" },
{ value: "ne", label: "does not equal" },
{ value: "in", label: "is one of" },
{ value: "not_in", label: "is not one of" },
{ value: "contains", label: "contains" },
{ value: "regex", label: "matches pattern" },
],
country: [
{ value: "eq", label: "equals" },
{ value: "ne", label: "does not equal" },
{ value: "in", label: "is one of" },
{ value: "not_in", label: "is not one of" },
],
txn_type: [
{ value: "in", label: "is one of" },
{ value: "not_in", label: "is not one of" },
],
};
const ACTION_OPTIONS = [
{ value: "create_alert", label: "Create alert", needsParam: false },
{ value: "add_score", label: "Add risk score", needsParam: true, paramName: "score", paramLabel: "Score to add", paramType: "number" },
{ value: "tag", label: "Tag transaction", needsParam: true, paramName: "tag", paramLabel: "Tag", paramType: "text" },
{ value: "escalate", label: "Escalate", needsParam: false },
{ value: "block", label: "Block", needsParam: false },
];
const STATUS_PILL = {
active: "ok",
draft: "med",
paused: "warn",
archived: "low",
};
function getFieldType(field) {
return FIELD_OPTIONS.find(f => f.value === field)?.type || "text";
}
function getFieldLabel(field) {
return FIELD_OPTIONS.find(f => f.value === field)?.label || field;
}
function getOperatorLabel(op) {
for (const type of Object.values(OPERATORS_BY_TYPE)) {
const found = type.find(o => o.value === op);
if (found) return found.label;
}
return op;
}
function formatValue(value, operator) {
if (value == null) return "—";
if (Array.isArray(value)) return value.join(", ");
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function conditionToSentence(condition) {
if (!condition) return "No condition";
if (condition.operator === "and" || condition.operator === "or") {
const parts = (condition.conditions || []).map(conditionToSentence).filter(Boolean);
if (parts.length === 0) return "No condition";
if (parts.length === 1) return parts[0];
return parts.join(` ${condition.operator.toUpperCase()} `);
}
if (condition.operator === "not") {
const sub = condition.conditions?.[0];
return sub ? `NOT (${conditionToSentence(sub)})` : "NOT (empty)";
}
return `${getFieldLabel(condition.field)} ${getOperatorLabel(condition.operator)} ${formatValue(condition.value, condition.operator)}`;
}
function isListOperator(op) {
return op === "in" || op === "not_in";
}
function isBetweenOperator(op) {
return op === "between";
}
function parseListValue(raw) {
return String(raw || "").split(",").map(s => s.trim()).filter(Boolean);
}
function serializeListValue(value) {
return Array.isArray(value) ? value.join(", ") : String(value || "");
}
function ensureNumberArray(value) {
if (Array.isArray(value) && value.length === 2) return [Number(value[0]) || 0, Number(value[1]) || 0];
return [0, 0];
}
function ConditionRow({ condition, onChange, onRemove, showRemove }) {
const fieldType = getFieldType(condition.field);
const operators = OPERATORS_BY_TYPE[fieldType] || OPERATORS_BY_TYPE.text;
const updateField = (field) => {
const newType = getFieldType(field);
const newOperators = OPERATORS_BY_TYPE[newType] || OPERATORS_BY_TYPE.text;
const newOp = newOperators.find(o => o.value === condition.operator)?.value
? condition.operator
: newOperators[0].value;
onChange({ ...condition, field, operator: newOp, value: isListOperator(newOp) ? [] : (isBetweenOperator(newOp) ? [0, 0] : "") });
};
const updateOperator = (operator) => {
let value = condition.value;
if (isListOperator(operator) && !Array.isArray(value)) value = [];
if (isBetweenOperator(operator) && !Array.isArray(value)) value = [0, 0];
if (!isListOperator(operator) && !isBetweenOperator(operator) && Array.isArray(value)) value = "";
onChange({ ...condition, operator, value });
};
const renderValueInput = () => {
if (fieldType === "txn_type") {
const selected = Array.isArray(condition.value) ? condition.value : (condition.value ? [condition.value] : []);
return (
{TRANSACTION_TYPES.map(t => {
const checked = selected.includes(t);
return (
);
})}
);
}
if (isListOperator(condition.operator)) {
return (
onChange({ ...condition, value: parseListValue(e.target.value) })}
placeholder="comma-separated values"
/>
);
}
if (isBetweenOperator(condition.operator)) {
const [min, max] = ensureNumberArray(condition.value);
return (
onChange({ ...condition, value: [Number(e.target.value), max] })} placeholder="min" />
and
onChange({ ...condition, value: [min, Number(e.target.value)] })} placeholder="max" />
);
}
if (fieldType === "number") {
return (
onChange({ ...condition, value: Number(e.target.value) })}
placeholder={FIELD_OPTIONS.find(f => f.value === condition.field)?.placeholder || ""}
/>
);
}
return (
onChange({ ...condition, value: e.target.value })}
placeholder={FIELD_OPTIONS.find(f => f.value === condition.field)?.placeholder || ""}
/>
);
};
return (
{renderValueInput()}
{showRemove && (
)}
);
}
function ConditionBuilder({ ruleType, condition, onChange }) {
const isComposite = ruleType === "composite";
if (!isComposite) {
return (
);
}
const composite = condition?.operator === "and" || condition?.operator === "or"
? condition
: { operator: "and", conditions: [condition || { operator: "gt", field: "amount", value: "" }] };
const updateLogic = (operator) => onChange({ ...composite, operator });
const updateCondition = (idx, newCond) => {
const conditions = [...composite.conditions];
conditions[idx] = newCond;
onChange({ ...composite, conditions });
};
const addCondition = () => {
onChange({ ...composite, conditions: [...composite.conditions, { operator: "gt", field: "amount", value: "" }] });
};
const removeCondition = (idx) => {
const conditions = composite.conditions.filter((_, i) => i !== idx);
onChange({ ...composite, conditions });
};
return (
Match
of the following conditions
{composite.conditions.map((c, i) => (
updateCondition(i, newC)}
onRemove={() => removeCondition(i)}
showRemove={composite.conditions.length > 1}
/>
))}
);
}
function ActionBuilder({ actions, onChange }) {
const updateAction = (idx, updates) => {
const next = actions.map((a, i) => i === idx ? { ...a, ...updates } : a);
onChange(next);
};
const addAction = () => onChange([...actions, { action: "create_alert", params: {} }]);
const removeAction = (idx) => onChange(actions.filter((_, i) => i !== idx));
return (
{actions.map((action, i) => {
const meta = ACTION_OPTIONS.find(a => a.value === action.action) || ACTION_OPTIONS[0];
return (
{meta.needsParam && (
updateAction(i, { params: { [meta.paramName]: meta.paramType === "number" ? Number(e.target.value) : e.target.value } })}
placeholder={meta.paramLabel}
style={{ minWidth: 120 }}
/>
)}
{actions.length > 1 && (
)}
);
})}
);
}
function RuleEditor({ rule, onSave, onCancel }) {
const isNew = !rule;
const [form, setForm] = useRuleState(() => {
const base = rule ? { ...rule } : RULE_TEMPLATES.threshold;
return {
...base,
tags: Array.isArray(base.tags) ? base.tags.join(", ") : "",
};
});
const [error, setError] = useRuleState(null);
const [saving, setSaving] = useRuleState(false);
const update = (key, value) => setForm(f => ({ ...f, [key]: value }));
const applyTemplate = (type) => {
const tpl = RULE_TEMPLATES[type];
setForm(f => ({
...f,
...tpl,
name: f.name || tpl.name,
description: f.description || tpl.description,
tags: f.tags || "",
}));
};
const validate = () => {
if (!form.name.trim()) return "Rule name is required";
if (form.rule_type === "composite") {
if (!form.condition || !Array.isArray(form.condition.conditions) || form.condition.conditions.length === 0) {
return "Composite rules need at least one condition";
}
} else {
if (!form.condition || !form.condition.field || !form.condition.operator) {
return "Condition is incomplete";
}
}
if (!Array.isArray(form.actions) || form.actions.length === 0) return "At least one action is required";
return null;
};
const submit = async (e) => {
e.preventDefault();
setError(null);
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
const payload = {
name: form.name,
description: form.description || null,
rule_type: form.rule_type,
severity: form.severity,
priority: Number(form.priority),
condition: form.condition,
actions: form.actions,
lookback_seconds: Number(form.lookback_seconds),
aggregation_key: form.aggregation_key || null,
effective_from: form.effective_from || null,
tags: form.tags.split(",").map(t => t.trim()).filter(Boolean),
};
setSaving(true);
try {
await onSave(payload);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
return (
{isNew ? "New detection rule" : "Edit rule"}
);
}
function SimulationPanel({ rules }) {
const [selectedRuleId, setSelectedRuleId] = useRuleState("");
const [amountThreshold, setAmountThreshold] = useRuleState("");
const [countThreshold, setCountThreshold] = useRuleState("");
const [startDate, setStartDate] = useRuleState("");
const [endDate, setEndDate] = useRuleState("");
const [entityId, setEntityId] = useRuleState("");
const [loading, setLoading] = useRuleState(false);
const [result, setResult] = useRuleState(null);
const [error, setError] = useRuleState(null);
useRuleEffect(() => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 90);
setEndDate(end.toISOString().slice(0, 10));
setStartDate(start.toISOString().slice(0, 10));
}, []);
useRuleEffect(() => {
if (rules && rules.length && !selectedRuleId) {
const first = rules[0];
setSelectedRuleId(first.id);
setAmountThreshold(String(first.condition?.value || ""));
}
}, [rules]);
const run = async () => {
if (!selectedRuleId || !startDate || !endDate) return;
setLoading(true);
setError(null);
setResult(null);
try {
const overrides = {};
const overrideCondition = {};
if (amountThreshold !== "") overrideCondition.value = Number(amountThreshold);
if (countThreshold !== "") overrideCondition.count = Number(countThreshold);
if (Object.keys(overrideCondition).length) {
overrides[selectedRuleId] = overrideCondition;
}
const body = {
rule_ids: [selectedRuleId],
condition_overrides: overrides,
start_date: new Date(startDate).toISOString(),
end_date: new Date(endDate + "T23:59:59Z").toISOString(),
entity_scope: entityId.trim() ? "specific_entity" : "all",
entity_id: entityId.trim() || null,
};
const res = await window.mossadApi.simulateRules(body);
setResult(res);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const selectedRule = rules.find(r => r.id === selectedRuleId);
return (
Rule backtesting
Simulate thresholds without changing production rules
Rule
{error && (
{error}
)}
{result && (
{result.total_alerts_generated.toLocaleString()}
Alerts generated
{result.unique_entities_flagged.toLocaleString()}
Entities flagged
{result.existing_cases_covered.toLocaleString()}
Existing cases covered
{result.existing_sars_covered.toLocaleString()}
Existing SARs covered
Rule breakdown
| Rule |
Type |
Evaluated |
Alerts |
Entities |
{result.rule_breakdown.map(rb => (
| {rb.rule_name} |
{rb.rule_type} |
{rb.transactions_evaluated.toLocaleString()} |
{rb.alerts_generated.toLocaleString()} |
{rb.unique_entities.toLocaleString()} |
))}
{result.sample_alerts.length > 0 && (
<>
Sample alerts
| Entity |
Rule |
Txn ID |
Amount |
Severity |
{result.sample_alerts.slice(0, 10).map((sa, i) => (
| {sa.primary_entity_id} |
{sa.rule_name} |
{sa.external_id || sa.transaction_id.slice(0, 8)} |
${Number(sa.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {sa.currency} |
{sa.severity} |
))}
>
)}
)}
);
}
function RulesView({ data }) {
const rules = data?.rules || window.RULES_API || [];
const [editing, setEditing] = useRuleState(null);
const [filter, setFilter] = useRuleState("all");
const [busyId, setBusyId] = useRuleState(null);
const [tab, setTab] = useRuleState("rules");
const filtered = useRuleMemo(() => {
if (filter === "all") return rules;
return rules.filter(r => r.status === filter);
}, [rules, filter]);
const handleSave = async (payload) => {
const api = window.mossadApi;
if (editing && editing.id) {
await api.updateRule(editing.id, payload);
} else {
await api.createRule(payload);
}
await data.refresh();
setEditing(null);
};
const setStatus = async (rule, status) => {
setBusyId(rule.id);
try {
await window.mossadApi.updateRule(rule.id, { status });
await data.refresh();
} catch (err) {
alert("Failed to update rule: " + err.message);
} finally {
setBusyId(null);
}
};
const removeRule = async (rule) => {
if (!confirm(`Delete rule "${rule.name}"? This cannot be undone.`)) return;
setBusyId(rule.id);
try {
await window.mossadApi.deleteRule(rule.id);
await data.refresh();
} catch (err) {
alert("Failed to delete rule: " + err.message);
} finally {
setBusyId(null);
}
};
if (editing) {
return (
setEditing(null)} />
);
}
return (
Rule Settings
{tab === "rules" && (
<>
>
)}
{["rules", "simulation"].map(t => (
))}
{tab === "simulation" &&
}
{tab === "rules" && filtered.length === 0 && (
No rules match the selected filter.
)}
{tab === "rules" && (
{filtered.map(r => (
{r.name}
{r.rule_type} · priority {r.priority}
{r.status}
{r.severity}
{r.description && (
{r.description}
)}
Triggers when {conditionToSentence(r.condition)}
Lookback: {(Number(r.lookback_seconds) / 86400).toLocaleString(undefined, { maximumFractionDigits: 2 })}d
{r.aggregation_key ? ` · Aggregate by ${r.aggregation_key}` : ""}
{r.effective_from ? ` · Effective: ${new Date(r.effective_from).toLocaleString()}` : ""}
{Array.isArray(r.tags) && r.tags.length > 0 ? ` · ${r.tags.join(", ")}` : ""}
{r.status !== "active" && (
)}
{r.status === "active" && (
)}
{r.status !== "archived" && (
)}
{r.status === "archived" && (
)}
))}
)}
);
}
Object.assign(window, { RulesView });