Skip to content

Case Notifications

zach115th edited this page Jul 31, 2026 · 1 revision

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.

What counts as an update

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.

The read watermark

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.

First sight of a case

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.

The panel

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.

API

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:

{
  "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
}

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.

Implementation

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.

Upgrading

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.

Clone this wiki locally