Inbox & notifications

The inbox is not a log but reminders aggregated by source: when the same CategoryKey fires again nothing new is inserted — the existing record is refreshed, marked unread again and its occurrence counter incremented. That is why a notification you just read can flip back to unread: it is by design.

Sources system · scheduled · workflow · kanban Aggregate by CategoryKey same key, unarchived → merge Merge action refresh · mark unread · count +1 Reads list · per-category counts · unread total Actions single read / archive · batch read / unread / archive / unarchive / delete · soft-delete one Clicking a notification marks it read, then follows its link (an empty link only marks read)
The aggregation key is the category key: repeated events from one source keep a single record with a counter, so the inbox is not flooded by one noisy producer.

Model and fields

Field / constantValues
InboxNotificationCategory · CategoryKey · Level · Title · Content · IsRead · IsArchived · OccurrenceCount · Link · IsDeleted
Levelinfo · success · warning · error
Categorysystem · scheduledTask · workflow · kanbanTask
Batch actionsread · unread · archive · unarchive · delete
Database constraintsCategory required; length limits on CategoryKey / Level / Title / Content / Link; indexes on CategoryKey / IsArchived / UpdatedAt; soft deletes filtered out

Aggregation rules

  1. On write, if CategoryKey is non-empty and an unarchived record with the same key exists, the write merges into it.
  2. Merging refreshes Category / Level / Title / Content / Link, sets IsRead = false and IsArchived = false, and increments OccurrenceCount.
  3. With no matching unarchived record a new one is created with OccurrenceCount = 1.
  4. Archiving moves a record out of the aggregation scope, so the same source firing later creates a new entry.

Endpoints

EndpointPurpose
GET /api/inbox?archived=&category=&page=&pageSize=Paged list filtered by archive state and category
GET /api/inbox/categories?archived=Per-category totals and unread counts
GET /api/inbox/unread-countTotal unread count (for the badge)
POST /api/inboxWrite a notification (through the aggregation logic)
POST /api/inbox/{id}/readMark one read (or set an explicit target state via InboxStateRequest.Value)
POST /api/inbox/{id}/archiveArchive one record
POST /api/inbox/batchBatch: { ids, action }
DELETE /api/inbox/{id}Soft-delete one record

State transitions and deletion

  • Single-record read and archive each change exactly one field; they are independent.
  • Batch delete sets both IsDeleted and IsArchived, so the record is neither visible nor part of aggregation.
  • Soft-deleted records stay in the database and are simply filtered out by queries.

Frontend behaviour

  • Data comes from three React Query keys — list, category counts and unread count — and the page refreshes by query invalidation; there is no dedicated SSE stream or polling timer.
  • Clicking a notification marks it read first and then navigates using link.
  • Category labels are mapped locally (scheduled task / workflow / system); kanbanTask is reserved in the data layer and constants.

Practice notes

  • CategoryKey is the only aggregation input: give it a meaningful, stable value (for example task id plus event type), otherwise everything is either duplicated or unrelated events collapse together.
  • Merging resets the read flag on purpose — happening again means reminding again. If you really want silence, archive it.
  • The inbox is not an audit log: records get merged, archived and deleted. For history use run records (board), execution history (scheduled tasks) and checkpoints.