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.
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 / constant
Values
InboxNotification
Category · CategoryKey · Level · Title · Content · IsRead · IsArchived · OccurrenceCount · Link · IsDeleted
Level
info · success · warning · error
Category
system · scheduledTask · workflow · kanbanTask
Batch actions
read · unread · archive · unarchive · delete
Database constraints
Category required; length limits on CategoryKey / Level / Title / Content / Link; indexes on CategoryKey / IsArchived / UpdatedAt; soft deletes filtered out
Aggregation rules
On write, if CategoryKey is non-empty and an unarchived record with the same key exists, the write merges into it.
Merging refreshes Category / Level / Title / Content / Link, sets IsRead = false and IsArchived = false, and increments OccurrenceCount.
With no matching unarchived record a new one is created with OccurrenceCount = 1.
Archiving moves a record out of the aggregation scope, so the same source firing later creates a new entry.
Endpoints
Endpoint
Purpose
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-count
Total unread count (for the badge)
POST /api/inbox
Write a notification (through the aggregation logic)
POST /api/inbox/{id}/read
Mark one read (or set an explicit target state via InboxStateRequest.Value)
POST /api/inbox/{id}/archive
Archive one record
POST /api/inbox/batch
Batch: { 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.