| """routes_alerts.py β the Alerts module (wave 20, owner item 25, contract C-ALERT).
|
|
|
| GET /api/v1/alerts -> {alerts:[...]}
|
| POST /api/v1/alerts <- {viewId, topic, label?}
|
| DELETE /api/v1/alerts/{alert_id}
|
| POST /api/v1/alerts/{alert_id}/run -> evaluate now (the pane's manual refresh)
|
| GET /api/v1/notifications -> {unread, items:[...]}
|
| POST /api/v1/notifications/read <- {ids:[...]|null, read?:bool}
|
|
|
| The semantics β an alert is a view plus a remembered matched set, a notification is a NEW
|
| ENTRANT, and the first evaluation seeds silently β live in `core.alerts` with the reasoning.
|
| This file owns the two things a route must: WHO may do it, and HOW the view gets evaluated.
|
|
|
| β **THE EVALUATION RUNS AS THE ALERT'S OWNER, NOT AS THE CALLER.** `_run_alert` builds the pool
|
| for `rec['owner']`, never for whoever tripped the write hook. Any other choice leaks: a
|
| full-access admin editing a cell would otherwise evaluate a BU-scoped user's alert over the whole
|
| book, and the notification would name customers that user may not see β a permission leak wearing
|
| a notification's clothes. The owner's own scope is the only correct basis for their alert.
|
|
|
| β **AN ALERT IS NOT A SECOND READ PATH.** It resolves rows through the same
|
| `routes_customers.grid_assembly` / `routes_tables.ut_assembly` the grid uses, so a row that an
|
| alert can see is by construction a row its owner could open. Re-implementing the filter here
|
| would be a second definition of "matches", and those two would drift.
|
| """
|
| import re
|
|
|
| from fastapi import APIRouter, Body, Depends
|
|
|
| import core.alerts as alerts
|
| from deps import Session, err, require_session
|
|
|
| router = APIRouter(prefix="/api/v1")
|
|
|
|
|
| _TOPICS = ("customer", "product")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| NOTIF_KIND_ALERT = "alert"
|
| NOTIF_KIND_AUTOMATION = "automation"
|
| NOTIF_KIND_SHARE = "share"
|
|
|
|
|
| TARGET_MODULE_DATABASE = "database"
|
| TARGET_MODULE_AUTOMATION = "automation"
|
| TARGET_TAB_RUNS = "runs"
|
|
|
|
|
|
|
|
|
| SHARE_TOPIC = "share"
|
|
|
|
|
| AUTOMATION_TOPIC = "automation"
|
|
|
| _UT_TOPIC = re.compile(r"ut_[A-Za-z0-9_]+\Z")
|
|
|
|
|
| def route_for_topic(topic):
|
| """A grid SCOPE key -> the registry route that renders it, or None.
|
|
|
| β THE SAME TABLE AS `alertsModel.routeForTopic`, and the parity is GATED
|
| (`verify_alerts.py`'s vocabulary scan) rather than trusted. The two built-ins are the only
|
| pair that differ β the registry names the surface (`customer_data`) while the grid names the
|
| scope (`customer`) β so a topic passed through as a route sends every click to a page that
|
| does not exist. `None` for anything else: a target this product cannot resolve must be ABSENT
|
| rather than plausible, because an absent target renders as a row that does not pretend to be
|
| clickable, and a wrong one renders as a click that silently goes nowhere.
|
| """
|
| t = str(topic or "").strip()
|
| if t == "customer":
|
| return "customer_data"
|
| if t == "product":
|
| return "product_data"
|
| if _UT_TOPIC.match(t):
|
| return t
|
| return None
|
|
|
|
|
| def _refusal_code(exc):
|
| """The `error.code` an `HTTPException` raised by `deps.err()` carries, or `""`.
|
|
|
| β W32-T22. Four refusals travel up the assembly chain β `unknown_table` (404), `forbidden`
|
| (403), `window_required` (409) and `store_not_ready` (503) β and each already names its own
|
| cause. Anything that reduces all four to one word is throwing away the only information the
|
| reader could have acted on. Returns `""` for a plain exception, so a caller can tell
|
| "refused, and here is why" apart from "broke, and we do not know why".
|
| """
|
| detail = getattr(exc, "detail", None)
|
| if isinstance(detail, dict):
|
| inner = detail.get("error")
|
| if isinstance(inner, dict):
|
| return str(inner.get("code") or "")
|
| return ""
|
|
|
|
|
| def notification_view(item):
|
| """One STORED notification -> the shape the Inbox renders. PURE, and total.
|
|
|
| Never raises and never drops a row: an item it cannot classify comes back as an `alert` with
|
| no `target`, which the client renders as an unclickable row rather than hiding. An inbox that
|
| silently omits what it does not understand is the one failure a reader cannot detect.
|
| """
|
| if not isinstance(item, dict):
|
| return item
|
| topic = str(item.get("topic") or "").strip()
|
| alert_id = str(item.get("alertId") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| if topic == AUTOMATION_TOPIC and alert_id:
|
| kind = NOTIF_KIND_AUTOMATION
|
| target = {"module": TARGET_MODULE_AUTOMATION, "id": alert_id, "tab": TARGET_TAB_RUNS}
|
| elif topic == SHARE_TOPIC and alert_id:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| kind = NOTIF_KIND_SHARE
|
| row_id = str(item.get("rowId") or "").strip()
|
| target = {"module": TARGET_MODULE_DATABASE, "id": alert_id,
|
| **({"tab": row_id} if row_id else {})}
|
| else:
|
| kind = NOTIF_KIND_ALERT
|
| route = route_for_topic(topic)
|
| view_id = str(item.get("viewId") or "").strip()
|
| target = None if route is None else (
|
| {"module": TARGET_MODULE_DATABASE, "id": route,
|
| **({"tab": view_id} if view_id else {})})
|
|
|
|
|
|
|
|
|
|
|
| subject = str(item.get("alertLabel") or "").strip() or str(item.get("label") or "").strip()
|
|
|
|
|
|
|
| out = {**item, "read": bool(item.get("read")), "kind": kind,
|
| "subject": subject or "Notification"}
|
| if target is not None:
|
| out["target"] = target
|
| return out
|
|
|
|
|
| def inbox_view(box):
|
| """`core.alerts.inbox()`'s answer, with every item put through {@link notification_view}.
|
|
|
| β `unread` IS NOT RECOUNTED. It is the ACCOUNT's number and `items` is one page of it; a
|
| recount here would make the badge a function of whatever this page happened to include, which
|
| is the exact defect `alertsModel.parseInbox`'s own header records from the other side.
|
| """
|
| if not isinstance(box, dict):
|
| return box
|
| items = box.get("items")
|
| if not isinstance(items, list):
|
| return box
|
| return {**box, "items": [notification_view(n) for n in items]}
|
|
|
|
|
| def _topic_or_400(raw):
|
| topic = str(raw or "").strip().lower()
|
| if topic.startswith("ut_") or topic in _TOPICS:
|
| return topic
|
| raise err(400, "bad_topic", f"topic must be one of {', '.join(_TOPICS)} or a ut_ table")
|
|
|
|
|
| def _owner_session(session: Session, owner: str):
|
| """A `Session` for the alert's OWNER (see the module note on why the owner, not the caller).
|
|
|
| β `Session` exposes `uname`/`admin` as PROPERTIES derived from `user`, not as fields β so an
|
| owner session is built by swapping the `user` RECORD and letting both derive themselves. An
|
| earlier version passed `uname=`/`admin=` to the constructor, which would have raised on the
|
| first write hook of the wave; the properties are the single definition of who a session is,
|
| and going around them is how a session with an admin flag and a non-admin record exists.
|
|
|
| Returns None when the owner is gone or deactivated β their alerts then stop evaluating rather
|
| than evaluating as somebody else, which is the fail-closed direction.
|
| """
|
| import core.users as users
|
|
|
| if str(owner) == str(session.uname):
|
| return session
|
| rec = (users.registry() or {}).get(str(owner))
|
| if not isinstance(rec, dict) or not rec.get("active", True):
|
| return None
|
|
|
|
|
|
|
| return Session(tenant=session.tenant, user=users._public(str(owner), rec),
|
| claims=session.claims, runtime=session.runtime)
|
|
|
|
|
| def _evaluate(session: Session, rec: dict, assemblies=None):
|
| """Resolve `rec`'s view over its topic AS THE ALERT'S OWNER, then fold the result in.
|
|
|
| ββ W31-T24 β `assemblies` IS A PER-REQUEST MEMO, KEYED `(topic, owner)`, and it is the whole
|
| of this ticket's server half. `/notifications` re-evaluates EVERY alert inline on read and each
|
| one built a FULL assembly β the pool, the workspace, `rows_from_pool` over every row. Two
|
| alerts on one view built that table twice; ten built it ten times. Nothing dedupes them,
|
| because each `_evaluate` was a closed call.
|
| β `(topic, owner)` and not `topic`: the assembly is built as the alert's OWNER (see the module
|
| note β evaluating a BU-scoped user's alert on a full-access admin's pool is a permission leak
|
| wearing a notification's clothes), so two owners on one topic are two DIFFERENT tables and
|
| must never share an entry. Getting that key wrong is the one way this optimisation could leak.
|
| β Passing nothing keeps the old behaviour exactly, which is what the create/run doors want:
|
| they evaluate ONE alert and a memo for a single call is pure overhead.
|
| """
|
| import aios_grid
|
| from harness import filter_eval
|
|
|
| owner_sess = _owner_session(session, rec.get("owner"))
|
| if owner_sess is None:
|
| return {"skipped": "owner_unavailable"}
|
| topic = str(rec.get("topic") or "")
|
| memo_key = (topic, str(owner_sess.uname))
|
| g = assemblies.get(memo_key) if isinstance(assemblies, dict) else None
|
| if g is None:
|
| try:
|
| if topic.startswith("ut_"):
|
| from routes_tables import ut_assembly
|
|
|
|
|
|
|
|
|
|
|
|
|
| g = ut_assembly(owner_sess, topic,
|
| storage_key=f"{owner_sess.tenant}:{topic}:{owner_sess.uname}",
|
| consume_corrections=False)
|
| else:
|
| from routes_customers import grid_assembly
|
| g = grid_assembly(owner_sess, scope=topic, consume_corrections=False)
|
| except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| return {"skipped": _refusal_code(e) or "unavailable", "detail": type(e).__name__}
|
| if isinstance(assemblies, dict):
|
| assemblies[memo_key] = g
|
|
|
| view = (g.get("views") or {}).get(str(rec.get("viewId")))
|
| if not isinstance(view, dict):
|
|
|
|
|
|
|
| return {"skipped": "view_missing"}
|
|
|
|
|
|
|
|
|
| rows = aios_grid.rows_from_pool(g["rows_src"], g["fields"], g["ws"].get("overlays"),
|
| derived=g.get("derived"))
|
| config = view.get("config") or view
|
| ctx = filter_eval.EvalCtx(
|
| cohort_sets={str(k): {str(p) for p in (v.get("memberPids") or ())}
|
| for k, v in (g.get("lists") or {}).items() if isinstance(v, dict)},
|
| measure_sets=g.get("measure_sets") or {},
|
| today=g.get("today"))
|
| pids = filter_eval.visible_pids(config.get("filters") or [], rows, g["fields"], ctx,
|
| member_pids=config.get("memberPids"))
|
| labels = {str(r.get("pid")): str(r.get("name") or r.get("pid")) for r in rows}
|
| return alerts.evaluate(rec.get("id"), [str(p) for p in pids],
|
| labels=labels, partial=False, st=session.runtime)
|
|
|
|
|
| @router.get("/alerts")
|
| def list_alerts(session: Session = Depends(require_session)):
|
| return {"alerts": alerts.list_alerts(user=session.uname, is_admin=session.admin,
|
| st=session.runtime)}
|
|
|
|
|
| @router.post("/alerts")
|
| def create_alert(body: dict = Body(default=None), session: Session = Depends(require_session)):
|
| body = body or {}
|
| view_id = str(body.get("viewId") or "").strip()
|
| if not view_id:
|
| raise err(400, "bad_view", "an alert needs the id of the view it watches")
|
| topic = _topic_or_400(body.get("topic"))
|
| _require_filtered_view(session, topic, view_id)
|
| import uuid
|
| aid = f"al_{uuid.uuid4().hex[:12]}"
|
| rec = alerts.create(aid, view_id=view_id, topic=topic, owner=session.uname,
|
| label=body.get("label") or "", st=session.runtime)
|
|
|
|
|
| outcome = _evaluate(session, rec)
|
| return {"alert": {**rec, "seeded": True}, "first": outcome}
|
|
|
|
|
| def _require_filtered_view(session: Session, topic: str, view_id: str):
|
| """400 unless `view_id` exists on `topic` AND actually narrows something.
|
|
|
| β AN ALERT ON AN UNFILTERED VIEW IS SILENTLY INCAPABLE OF ALERTING, which is worse than one
|
| that is refused. `filter_eval` treats an inactive tree as "no narrowing, every row shows"
|
| (`visible_pids`'s own rule), so such an alert seeds with the entire table and can never see an
|
| entrant again β there is nothing left to enter. The owner's words are *"when a Record gets
|
| into that Filter's criteria"*: no criteria, no alert, and said at creation rather than
|
| discovered by never being notified.
|
|
|
| `is_rule_active` is the SAME activeness predicate the engine and the column tints use β a
|
| half-typed rule is not a filter, and this must agree with what actually narrows or it would
|
| accept a view whose one rule the engine then ignores.
|
|
|
| ββ WAVE 32 Β· T22 (owner item 17) β THIS FUNCTION WAS THE ERROR. Two defects, stacked, and
|
| the second one hid the first.
|
|
|
| (1) **IT ASKED FOR EVERY ROW OF A TABLE IT NEVER LOOKS AT.** The only thing read below is
|
| `g["views"]`. `ut_assembly` defaults `with_rows=True`, so creating an alert on a
|
| read-through grid built the whole pool β and `scoped_pool` refuses that with
|
| `409 window_required` over 963,783 rows, exactly as it is supposed to. `with_rows=False`
|
| (W31-T20's flag, built for precisely this) answers the same question with `scoped_pids`,
|
| runs the SAME `_defn_or_refuse` wall, and does not refuse. **That is D-184's create half,
|
| closed** β an alert on a read-through grid can now be made at all.
|
| (2) **A BLANKET `except Exception` TURNED EVERY NAMED REFUSAL INTO A 503.** `HTTPException`
|
| is an `Exception`, so `404 unknown_table`, `403 forbidden`, `409 window_required` and
|
| `503 store_not_ready` β four refusals that each say what is wrong β were all replaced by
|
| *"the table is unavailable β try again in a moment"*. β AND THAT SENTENCE NEVER REACHED
|
| A USER EITHER: `alertsApi.errorMessage` discards the text of any status β₯ 500 by design
|
| (a 5xx body is the server's internals), substituting *"Something went wrong on our
|
| side."* β which is the owner's screenshot, word for word. A knowable cause returned as a
|
| 5xx is invisible by construction, so re-wording the 503 could never have fixed this.
|
| β The except is narrowed, not deleted: an UNEXPECTED failure is still a 503, because that is
|
| honest. What it may no longer do is catch a refusal that already knows its own name.
|
| """
|
| from fastapi import HTTPException
|
|
|
| from harness import filter_eval
|
|
|
| try:
|
| if topic.startswith("ut_"):
|
| from routes_tables import ut_assembly
|
|
|
|
|
|
|
|
|
| g = ut_assembly(session, topic,
|
| storage_key=f"{session.tenant}:{topic}:{session.uname}",
|
| consume_corrections=False, with_rows=False)
|
| else:
|
| from routes_customers import grid_assembly
|
| g = grid_assembly(session, scope=topic, consume_corrections=False)
|
| except HTTPException:
|
| raise
|
| except Exception as e:
|
|
|
|
|
| raise err(503, "unavailable",
|
| f"the table could not be read ({type(e).__name__}) β try again in a moment")
|
| view = (g.get("views") or {}).get(str(view_id))
|
| if not isinstance(view, dict):
|
| raise err(404, "no_view", "that view does not exist on this table")
|
| nodes, _conj = filter_eval.tree_parts((view.get("config") or view).get("filters") or [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| columns = filter_eval._columns_map(g.get("fields") or [])
|
|
|
| def _any_active(ns):
|
| for n in ns or ():
|
| if isinstance(n, dict) and isinstance(n.get("children"), list):
|
| if _any_active(n["children"]):
|
| return True
|
| elif filter_eval.is_rule_active(n, columns):
|
| return True
|
| return False
|
|
|
| if not _any_active(nodes):
|
| raise err(400, "no_filter",
|
| "this view has no active filter, so no record can ever ENTER it β add a "
|
| "condition to the view first, then create the alert")
|
|
|
|
|
| @router.delete("/alerts/{alert_id}")
|
| def delete_alert(alert_id: str, session: Session = Depends(require_session)):
|
| rec = next((r for r in alerts.list_alerts(st=session.runtime)
|
| if str(r.get("id")) == str(alert_id)), None)
|
| if rec is None:
|
| raise err(404, "no_alert", "that alert does not exist")
|
| if str(rec.get("owner")) != str(session.uname) and not session.admin:
|
| raise err(403, "not_yours", "only the alert's owner (or an administrator) can delete it")
|
| alerts.delete(alert_id, st=session.runtime)
|
| return {"ok": True}
|
|
|
|
|
| @router.post("/alerts/{alert_id}/run")
|
| def run_alert(alert_id: str, session: Session = Depends(require_session)):
|
| rec = next((r for r in alerts.list_alerts(user=session.uname, is_admin=session.admin,
|
| st=session.runtime)
|
| if str(r.get("id")) == str(alert_id)), None)
|
| if rec is None:
|
| raise err(404, "no_alert", "that alert does not exist")
|
| return _evaluate(session, rec)
|
|
|
|
|
| @router.get("/notifications")
|
| def notifications(session: Session = Depends(require_session)):
|
| """The inbox β RE-EVALUATED on read, which is a deliberate design choice.
|
|
|
| β A-S1-2 RESOLVED THE OTHER WAY, and the reason is structural rather than a shortcut. The
|
| plan was a push hook: the automation engine calls `after_write` when it lands rows. But
|
| `run_async` runs on a BACKGROUND THREAD with no `Session` in scope, and an alert must be
|
| evaluated as its OWNER (see `_evaluate`) β so a push hook would have to mint a session inside
|
| a worker thread from a tenant runtime, which is exactly the kind of ad-hoc identity
|
| construction that leaks scope.
|
|
|
| Pulling on read has none of that: the caller IS a session, the assemblies are already
|
| scope-cached, and the user cannot observe the difference β an inbox is only ever read by
|
| someone opening it. The cost is that a notification is minted when you LOOK rather than when
|
| the row landed, so the `at` stamp is detection time, not arrival time.
|
|
|
| `after_write` stays exported for the day the engine can hand over a real identity.
|
|
|
| ββ W31-T24 β ONE ASSEMBLY PER (TOPIC, OWNER), NOT ONE PER ALERT.
|
| β MEASURED FIRST, AND THE MEASUREMENT CORRECTS AN EARLIER READING OF IT. This route is
|
| **20 ms in-process and 3,280 ms live** on tenant #0 β but tenant #0 has **ZERO alerts**
|
| (censused 2026-08-12), so the 20 ms is an EMPTY LOOP and says nothing at all about what the
|
| re-evaluation costs. The live 3,280 ms is the two store reads either side of that loop. So the
|
| body below is not slow today; it is UNEXERCISED, and every alert a tenant creates adds a whole
|
| grid assembly to an inbox poll. The memo turns O(alerts) into O(distinct topic Γ owner), which
|
| is the difference between "fine" and "three seconds per alert" the day somebody uses the
|
| feature. β Making the read cheap by evaluating LESS is the obvious wrong fix and is not what
|
| this does: every alert is still evaluated, against the same rows, in the same order.
|
| """
|
| assemblies = {}
|
| for rec in alerts.list_alerts(user=session.uname, is_admin=False, st=session.runtime):
|
| try:
|
| _evaluate(session, rec, assemblies=assemblies)
|
| except Exception:
|
| continue
|
|
|
|
|
| return inbox_view(alerts.inbox(session.uname, st=session.runtime))
|
|
|
|
|
| @router.post("/notifications/read")
|
| def read_notifications(body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| body = body or {}
|
| ids = body.get("ids")
|
| if ids is not None and not isinstance(ids, list):
|
| raise err(400, "bad_ids", "ids must be a list, or null to mark every notification")
|
|
|
|
|
|
|
| return inbox_view(alerts.mark_read(session.uname, ids, read=bool(body.get("read", True)),
|
| st=session.runtime))
|
|
|
|
|
| def after_write(session: Session, topic_key: str):
|
| """THE WRITE HOOK β call after a write that could change what a view matches.
|
|
|
| Exported as a plain function (not a route) so `core.grid_events`' callers and S2's automation
|
| upserts reach it the same way. It never raises: an alert evaluation failing must not fail the
|
| edit that triggered it.
|
|
|
| β W31-T24 β it shares `/notifications`' memo shape for the same reason: a write that changes
|
| one view can trip several alerts on the SAME topic, and each would otherwise rebuild the table.
|
| β STILL ZERO PRODUCTION CALLERS (W31-T24 confirmed it; the route docstring above says why the
|
| push hook was resolved the other way). Booked rather than wired: minting a session inside the
|
| engine's worker thread is the ad-hoc identity construction this file exists to avoid.
|
| """
|
| try:
|
| assemblies = {}
|
| return alerts.after_write(topic_key, st=session.runtime,
|
| runner=lambda rec: _evaluate(session, rec,
|
| assemblies=assemblies))
|
| except Exception:
|
| return {"evaluated": 0}
|
|
|