9.1 App Notifications
Controller: AppNotificationController · Route: api/v1/AppNotification · GroupName: notifications
· 6 endpoints · Manages the current user's in-app notification inbox — the InApp channel's durable record.
This controller is intentionally read/manage-only from the caller's own perspective — there is no
Create/Update endpoint, because AppNotification rows are created exclusively as a side effect of the
dispatch pipeline (Chapter 6), never directly by a client.
POST GetInbox
Purpose: paginated, filterable list of the current user's notifications (DataTable-style grid).
Permission: Permissions.AppNotifications.View
Request:
{
"draw": 1,
"start": 0,
"length": 20,
"search": { "value": "", "regex": false },
"order": [ { "column": 0, "dir": "desc" } ],
"columns": [ { "data": "sentOn", "name": "", "searchable": false, "orderable": true } ]
}
Response (DtResult<AppNotificationDto>, unwrapped — no Result<T> envelope):
{
"draw": 1,
"recordsTotal": 34,
"recordsFiltered": 34,
"data": [
{
"id": "8b1e2f40-0000-0000-0000-000000000001",
"recipientUserId": "5f1a3b2c-0000-0000-0000-000000000001",
"title": "تم اعتماد فاتورة الشراء INV-2026-00451",
"body": "تم اعتماد الفاتورة بمبلغ 12500.00 ر.س بتاريخ 2026-07-04",
"channel": 4,
"channelName": "InApp",
"isRead": false,
"readOn": null,
"sentOn": "2026-07-04T09:12:03Z",
"eventKey": "PurchaseInvoicePosted",
"refType": "PurchaseInvoice",
"refId": "1a2b3c4d-0000-0000-0000-000000000099",
"isActive": true,
"displayOrder": 0,
"createdOn": "2026-07-04T09:12:03Z",
"lastModifiedOn": null,
"createdBy": "00000000-0000-0000-0000-000000000000",
"lastModifiedBy": null,
"is_Deleted": false,
"deletedOn": null,
"deletedBy": null
}
]
}
Business notes: the query is scoped server-side to RecipientUserId == currentUser.GetUserId() — a
caller can never retrieve another user's inbox through this endpoint, regardless of View/ViewAll
permission (note PermissionConstants.AppNotifications.ViewAll is defined but not referenced by any action
in this controller — likely reserved for a future admin-facing endpoint).
Angular: poll or refresh on SignalR "messages" event receipt (see Chapter 10) rather
than on a fixed timer, so the inbox grid updates immediately when a real-time push arrives.
Flutter: same pattern — refresh the inbox list when the mobile SignalR/Firebase listener fires, plus a
pull-to-refresh gesture.
Backend: AppNotificationService.GetInboxAsync — LINQ projection filtered by current user, standard
DataTable pipeline (_repository.DataTableFilter).
Related APIs: GetUnreadCount, MarkAsRead.
GET GetUnreadCount
Purpose: badge counter — total unread notifications for the current user.
Permission: Permissions.AppNotifications.View
Request: no body, no parameters.
Response:
{ "succeeded": true, "message": null, "data": 7 }
Angular: call on app bootstrap and after every MarkAsRead/MarkAllAsRead call to keep a header badge in
sync; also refresh on SignalR "messages" receipt.
Flutter: same — typically bound to a bottom-nav badge.
Backend: COUNT(*) WHERE RecipientUserId = currentUser AND IsRead = false AND Is_Deleted != true.
Related APIs: GetInbox, MarkAllAsRead.
GET GetDetails/{id}
Purpose: full detail view of a single notification (e.g. when the user taps a row in the inbox).
Permission: Permissions.AppNotifications.View
Response (AppNotificationDetailsDto):
{
"succeeded": true,
"message": null,
"data": {
"id": "8b1e2f40-0000-0000-0000-000000000001",
"recipientUserId": "5f1a3b2c-0000-0000-0000-000000000001",
"title": "تم اعتماد فاتورة الشراء INV-2026-00451",
"body": "تم اعتماد الفاتورة بمبلغ 12500.00 ر.س بتاريخ 2026-07-04",
"channel": 4,
"channelName": "InApp",
"isRead": false,
"readOn": null,
"sentOn": "2026-07-04T09:12:03Z",
"eventKey": "PurchaseInvoicePosted",
"refType": "PurchaseInvoice",
"refId": "1a2b3c4d-0000-0000-0000-000000000099",
"isActive": true,
"displayOrder": 0,
"createdBy": "System",
"createdOn": "2026-07-04T09:12:03Z",
"lastModifiedBy": null,
"lastModifiedOn": null,
"imageUrl": null
}
}
Possible errors: succeeded:false with an entity-not-found message if id doesn't exist or belongs to
another tenant (blocked by the ambient tenant query filter).
Related APIs: GetInbox.
PUT MarkAsRead/{id}
Purpose: mark a single notification as read (sets IsRead = true, ReadOn = now).
Permission: Permissions.AppNotifications.MarkRead
Request: id in the route, no body.
Response:
{ "succeeded": true, "message": "AppNotification is updated successfully", "data": "8b1e2f40-0000-0000-0000-000000000001" }
Angular: call when the user opens/expands a notification row; optimistically decrement the unread badge
locally, then reconcile with a GetUnreadCount call.
Flutter: call on tap; same optimistic-update pattern.
Related APIs: MarkAllAsRead.
PUT MarkAllAsRead
Purpose: batch mark every unread notification for the current user as read.
Permission: Permissions.AppNotifications.MarkRead
Request: no body.
Response (returns the count of rows updated):
{ "succeeded": true, "message": "7 notifications marked as read", "data": 7 }
Angular: call from an inbox "Mark all as read" action; refresh GetInbox and zero the badge immediately
after success.
Backend: AppNotificationService.MarkAllAsReadAsync performs a single batched update scoped to the
current user, not a per-row loop.
DELETE Delete
Purpose: soft-delete (or restore) one or more of the current user's notifications from their inbox.
Permission: Permissions.AppNotifications.Delete
Request (query ?restore=false|true, body is a DeleteDto[]):
DELETE /api/v1/AppNotification/Delete?restore=false
[ { "id": "8b1e2f40-0000-0000-0000-000000000001" } ]
Response:
{ "succeeded": true, "message": "AppNotification is deleted successfully", "data": "8b1e2f40-0000-0000-0000-000000000001" }
Business notes: this is a soft delete (Is_Deleted = true) purely from the recipient's inbox view — it
does not affect the underlying NotificationDeliveryAttempt/analytics history, which remain intact for
operator-facing reporting.