-
-
Notifications
You must be signed in to change notification settings - Fork 0
Case Notifications
A bell in the header shows what changed in the case you are currently working, scoped strictly to that case. Switching context switches the feed: acknowledging case #9 leaves case #11's unread state untouched, and opening the bell on #11 shows only #11's updates.
The badge shows the unread count for the current case. Opening the panel lists the changes; Mark as read clears them for that case only.
The feed reads the existing user_activity audit log rather than a parallel
notification store. Every mutation already writes a row there through
track_activity(), so nothing extra has to be emitted, and the feed works
retroactively over history that predates the feature.
Two filters apply:
| Rule | Effect |
|---|---|
display_in_ui = true |
Rows hidden from the activity UI stay hidden here |
| Not your own interactive edit | Being notified of your own change is noise |
The actor rule is deliberately narrower than "exclude myself". Rows carrying
is_from_api stay in even under your own account, because those come from
API clients, n8n workflows and module hooks rather than from the person reading
the panel. On a single-analyst instance the difference is a useful bell versus a
permanently empty one.
case_notification_ack holds one row per (user, case) with the timestamp of
the most recent activity that analyst has acknowledged for that case. No row
means never acknowledged.
The watermark is durable across logins — logging out clears nothing — and per case, so each case carries its own independent unread state.
Two properties are load-bearing:
Acknowledging is not "mark everything read up to now". The client echoes
back the newest timestamp it was actually shown (latest_at), and the server
acknowledges to exactly that. Anything that arrives between opening the panel
and clicking the button stays unread instead of being swallowed.
The watermark only ever moves forward. A stale or replayed acknowledgement cannot re-hide activity you have not seen.
With no watermark yet, the bell falls back to a 14-day lookback rather than
the full history — otherwise first sight of a long-running case opens on a wall
of rows going back to case creation. The panel header says which mode it is in
(last 14 days versus since you last read).
Nothing is lost: older activity remains in the case activity log, it simply is
not surfaced as unread. The window is DEFAULT_LOOKBACK_DAYS in
source/app/datamgmt/case/case_notifications_db.py.
Rows are text, taken from the activity description as written. Each carries a
coloured dot for the object type, the actor, a relative timestamp, and an API
tag when the change came from automation.
| Dot | Kind |
|---|---|
| Red | IOC |
| Blue | Asset |
| Amber | Timeline event |
| Green | Note |
| Orange | Task |
| Cyan | Evidence |
| Violet | Case / other activity |
The kind is derived from the description prefix (Added ioc …, Updated asset …) and falls back to a neutral dot when unrecognised, since a wrong icon is
worse than a plain one.
The list is capped at 50 rows; the count is not capped, so the badge stays
truthful and the footer shows Showing 50 of N. The badge polls every 60
seconds and refreshes whenever the panel is opened.
| Method | Path | Description |
|---|---|---|
GET |
/api/v2/cases/<cid>/notifications |
Unread list, counts and watermark |
GET |
/api/v2/cases/<cid>/notifications/count |
Badge number only |
POST |
/api/v2/cases/<cid>/notifications/ack |
Move the watermark (body: up_to) |
Every route is scoped to the case in the URL and to the calling user, so there is no way to read another analyst's watermark or another case's activity through it. Read access is sufficient to acknowledge — the watermark is the analyst's own reading state, not case data.
GET /notifications returns:
acknowledged_at is null and window_days is set only while the case has
never been acknowledged; afterwards the pair inverts.
POST /notifications/ack takes {"up_to": "<iso timestamp>"} — normally the
latest_at from the list call. Omitting it lets the server compute the current
maximum, which is convenient for scripts but gives up the race protection above.
A malformed timestamp returns 400.
Timestamps are serialized with an explicit Z. activity_date is stored naive
UTC, so a bare isoformat() would be read by the browser as local time — the
same convention the working timeline follows.
| Concern | Location |
|---|---|
| Model |
CaseNotificationAck in source/app/models/models.py
|
| Migration | f4a92c7e1b58_add_case_notification_ack.py |
| Queries + watermark | source/app/datamgmt/case/case_notifications_db.py |
| Business layer | source/app/business/case_notifications.py |
| Endpoints | source/app/blueprints/rest/v2/cases/notifications.py |
| Bell markup | source/app/templates/includes/case_notifications_bell.html |
| Styles + behaviour | source/app/templates/includes/case_notifications_assets.html |
The bell is a pair of shared partials included from both navigation
templates — navigation.html (dashboard and manage pages) and
navigation_ext.html (case pages). Adding a header element to only one of them
makes it disappear on half the application; see
Development Guide → Two navigation templates.
UNIQUE(user_id, case_id) is declared on the model's __table_args__, not only
in the migration, because db.create_all() runs ahead of Alembic — see
Development Guide → ORM CHECK constraints.
Behaviour is vanilla JS wired from DOMContentLoaded, because the navigation
templates render well before jQuery loads.
Nothing to do. run_post_init() calls db.create_all() and then
alembic upgrade head on every boot, so the new table is created automatically
on an existing database during a normal pull-and-recreate. Verified by dropping
the table, rewinding alembic_version to the previous revision and restarting:
the table returns with every constraint intact.
The feature touches only source/app/, which is bind-mounted, and adds no
static assets — so a restart is enough and no image rebuild is required.
{ "total": 18, "items": [ { "id": 412, "at": "2026-07-29T13:13:43.965273Z", "text": "Added task \"hunt 203.0.113.47 across vpn, entra id and proxy logs\"", "kind": "task", "by": "administrator", "from_api": true, "is_note": false } ], "truncated": false, "latest_at": "2026-07-29T13:13:43.965273Z", "acknowledged_at": null, "window_days": 14 }