Description
Purpose and use
Notifications deliver operational messages to users across in-app, email, SMS, and other configured channels. They keep customers and staff informed about account events, tasks, transfers, invoices, signatures, and service actions.
Who uses this. Customers, operations staff, support teams, compliance reviewers, and template administrators use notifications to receive, configure, or investigate event-driven messages.
How it works. A business event creates a notification with type, recipient, customer context, metadata, and delivery preferences. Templates and user preferences determine wording and channel delivery.
What users do. Users review notification lists, mark messages read, receive real-time updates, manage preferences, and troubleshoot missed or duplicate messages.
Outcomes and side effects. Notifications create communication history and can trigger unread counts or external delivery. They do not replace the authoritative audit trail for money movement or approval decisions.
Related manuals: Notification integration, Template admin, Users, Tasks.
Overview
The Notifications module provides a comprehensive real-time notification system for the CoreBanq platform. It supports multi-channel delivery, user preference management, and async processing through queue integration. The system enables real-time communication with users through various channels including email, SMS, and browser notifications.
Core Features
🔔 Real-Time Notifications
- Server-Sent Events (SSE): Real-time browser notifications
- WebSocket-like Experience: Persistent connections for instant delivery
- Multi-User Support: Broadcast notifications to multiple users
- Connection Management: Automatic client registration and cleanup
📡 Multi-Channel Delivery
- System Notifications: In-app notifications via SSE
- Email Integration: HTML/text email notifications
- SMS Integration: Text message notifications
- Extensible Architecture: Easy addition of new channels (WhatsApp, Telegram, etc.)
User preference management — not implemented
Both preference routes are stubs. GET and PUT /v1/notifications/preferences discard the request
entirely and answer 200 {"message": "Not implemented"}. Nothing is stored, nothing is read, and
the PUT does not even decode the body — a client that submits preferences gets a 200 and loses
them silently. Do not build against these routes.
🔄 Queue-Based Processing
- Async Processing: Non-blocking notification delivery
- Message Queuing: Reliable delivery with queue persistence
- Batch Processing: Efficient handling of multiple notifications
- Error Recovery: Retry mechanisms for failed deliveries
🗃️ Complete State Management
- Notification Storage: Persistent storage of all notifications
- Read/Unread Tracking: Individual read state per user
- Delivery Confirmation: Track delivery status across channels
- Historical Data: Complete notification history and analytics
API Endpoints
Get User Notifications
GET /v1/notifications
Returns the shared list envelope — data, total, total_unfiltered and has_more always,
keys and metadata when non-empty — scoped through the notification record type, so row-level
grants apply.
That RBAC scope is the only one — these are not "the authenticated user's own rows". Neither
the handler nor the queries behind it add a user_id predicate, so what a caller sees is decided
entirely by GetPermittedRecords: a caller holding read_all on the notification record type — an
Administrator, for instance — gets every user's notifications, and any other caller gets the
rows they hold a read grant on, whoever they belong to. Pass search.user_id to narrow the list to
one user.
stack turns data from an array into an object. With ?stack=read the body is
"data": {"true": [...], "false": [...]} — keyed by the formatted value of the stacked field — and
keys lists those group keys in the requested order. Without stack, data is the array and
keys is absent entirely. stack accepts the same field names as the filter list, optionally
prefixed with - for descending and suffixed with [format]; an unknown field is 400
query_m.invalid_field and an unterminated bracket is 400 query_m.invalid_format. Like sort,
both refusals arrive only once rows have been fetched — a filter that matched nothing returns the
empty stacked shape without parsing the value at all.
keys is not a list of field names. It exists only on the stacked path and holds the group
keys of data.
Query parameters
limit(optional): default 10, not 50. Over 100 clamps to 100; zero or negative becomes 10;-1is the shared skip-data-fetching sentinel. A non-integer is400.offset(optional): default 0; a negative value is clamped to 0.sort(optional): a filterable field name,-for descending.stack(optional): changes the shape of the response. See below.distinct(optional): passed to the shared query layer.customer_id(optional): not a scope.GetNotificationsSrvuses it for one thing only — picking the language for the renderedtitleandcontent, throughGetUserPreferredLanguage. It filters nothing; usesearch.customer_idfor that. Omitted, it stays the nil UUID and is passed through as such. A value that does not parse is400— but withcommon.invalid_input, because the handler discards the specificnotifications_m.invalid_idthe service built and callsBadRequest400with no argument.- There is no
statusparameter. Usesearch.read=trueorsearch.read=false.
Filterable fields
id, customer_id, user_id, event, title, content, rec_id, rec_type, created_at,
sent_at, read, read_at.
Three traps. title and content do not hold what the user sees. Both filter against the
stored columns, and CreateUserNotifications never assigns Title at all — so
user_notifications.title is empty on every row and search.title can never match anything.
content holds only the caller-supplied pre-rendered text, so it is empty for key-based
notifications. The keys live in title_key and content_key, which are hidden from JSON and are
not filterable. The title and content in the response are rendered per request, after the
filter has already run — and on this route they come from the event push template whenever one
resolves, not from the keys. See the section below.
title and content are resolved three different ways
The same row does not read the same on every route that returns it.
| Route | title | content |
|---|---|---|
GET /v1/notifications | Push template for the event rendered with the row's metadata; falls back to title_key | Same template — replacing the stored text; falls back to stored content, then content_key |
GET /v1/notifications/{id} | title_key only — the template is never consulted | Stored content when non-empty, otherwise content_key |
PATCH /{id}/read | The raw stored column, which is empty on every row | The raw stored text, untranslated |
buildInAppNotificationMessage succeeds whenever a push template exists for the event, and
unconditionally for the customer-status events. So a chat notification listed and then opened shows
the templated summary in the list and the stored message body on the detail view — same row, two
answers.
link_url and link_text follow the same split: applyNotificationLinkFields is called from the
list loop and from the SSE/channel payload builder and nowhere else, so the read-one and the
mark-read always omit both, even for an event that has a link configured.
Any search. outside the list is rejected, and rejected as a 500:
query.validateFieldName builds query_m.invalid_search_field without a status, and an empty
Code sends HandleAppErrorWithCode to InternalServerError500. A caller error arrives wearing a
server error's status.
sort is validated too, but only on the fetch pass — applyParams skips sorting when counting.
So an unknown sort field is a 400 query_m.invalid_sort_field when the filter matched at least
one row, and goes unnoticed when the count came back zero or limit=-1 skipped the fetch: those
answer 200 with an empty page.
Response
Get Single Notification
GET /v1/notifications/{notification_id}
Retrieve a specific notification by ID.
title and content are resolved from the keys only — this route never consults the event
push template, so a templated notification reads differently here than in the list, and link_url
and link_text are never populated at all. See the resolution table above.
A 404 is raised after the storage read succeeds, by checking whether the returned row has the
nil id, so a row the caller may not see is reported as absent rather than forbidden.
Response
Mark Notification as Read
PATCH /v1/notifications/{notification_id}/read
Marks the notification read, and only read. It is not a generic update endpoint: read is the
only field the request struct declares, and everything else in the body is ignored.
The row comes back unresolved. No title_key lookup, no push template, no link fields — the
response is whatever UPDATE … RETURNING produced, so title is the empty stored column and
content is the raw stored text, untranslated.
read is a presence flag, not a value. Whatever it carries — true or false — the row is
written SET read = true, read_at = now(). The service only nil-checks patch.Read and never
passes it down, and the storage statement hard-codes the column. There is no way to mark a
notification unread through this API: no other route clears the flag.
Request body — required in practice
{
"read": true
}An id that matches no row of yours also answers 200. The statement is
UPDATE … RETURNING * scanned into a struct, and scanning zero rows is not an error — so an
unknown notification_id, or one belonging to another user, comes back as a fully zero-valued
notification (id all zeros, read false). There is no 404 on this route; check the returned
id before trusting the result.
Omitting read is a no-op that still answers 200, with the body null. The service returns
(nil, nil) when the decoded patch has no read member and the handler serialises that nil
pointer, so {} and {"read": null} both write nothing and return the four bytes null. The
older claim that an absent body marks the notification read is the opposite of what happens. Send
true; sending false does the same thing, not the opposite.
Response
Delete Notification
DELETE /v1/notifications/{notification_id}
This route addresses a different table from every other route in the module.
DeleteNotification loads models.Notification — notifications.notifications, the fan-out
source row — by primary key, while the list, the read-one and the mark-read all address
notifications.user_notifications, the per-recipient copy. Feeding an id taken from
GET /v1/notifications back into this route therefore finds no row.
The caller's user id is not a scope. It is only written into deleted_by; the lookup filters
on the primary key alone, and customer_id is not read either. Any caller the endpoint grant
admits can soft-delete any notification whose id they know, and because the row flagged is the
parent, the notification disappears for every recipient — GetUserNotificationByID joins the
parent and filters parent.deleted = false.
The delete is logical: deleted, deleted_at and deleted_by are set and the row is saved.
Response
200, not 204, and a bare map rather than the StdResponse envelope:
{"message": "Notification deleted"}The message is a fixed English string with no i18n key.
A missing row is a 500, not a 404. The storage layer wraps every failure of the lookup or
the save — gorm.ErrRecordNotFound included — in common.database_error with no status, and an
empty Code sends HandleAppErrorWithCode to InternalServerError500.
Preferences — both routes are stubs
GET /v1/notifications/preferences
PUT /v1/notifications/preferences
Neither handler reads its request or touches storage. Both answer, unconditionally:
{"message": "Not implemented"}with status 200. The PUT does not decode the body, so submitted preferences are discarded
without any indication. They cannot fail and they cannot return data.
Real-Time Connection (SSE)
GET /v1/notifications/sse
Establish a Server-Sent Events connection for real-time notifications.
Authentication: Bearer token required
Response Headers
The server sets the following on the stream:
Content-Type:text/event-streamCache-Control:no-cache, no-transformConnection:keep-aliveX-Accel-Buffering:no— disables proxy buffering (nginx and similar) so frames are not held until the response ends
This route has two different 503 bodies, and the graceful-shutdown map is neither of them.
health.LifecycleMiddleware skips /v1/notifications/sse, so that map never appears here. What
can:
- A draining SSE manager, answered by the handler through
http.Error:Content-Type: text/plain, body the lineserver shutting down. No JSON at all. - An unhealthy auth cache. The route is registered with
auth.WrapWithMiddlewares, soauth.MiddlewarerunsensureCacheAvailablebefore the handler is entered — an unreachable Redis/valkey refuses the connection with the JSON envelope andauth_m.internal_server_error, the auth package's own constant rather than theerrsone that readscommon.server_error.
Branch on the body shape, not on the status: the plain-text line means the stream is draining, the envelope means the cache is down.
Access-Control-Allow-Origin is echoed from the request's Origin header verbatim — on the
stream itself. The handler's OPTIONS branch is dead code and no preflight ever reaches it:
auth.CORSMiddleware is mounted on the root router ahead of everything and answers a preflight
carrying an Origin itself — a bodiless 200 when the origin is allowed, 403 when it is not —
while a preflight without an Origin header falls through to chi, which has only GET and
POST registered for this pattern and answers 405.
Connection Management
- Persistent Connections: SSE connections remain open until the client disconnects or the server shuts down
- Keep-Alive Heartbeat: The server sends a
: keepalivecomment frame everynotifications.sse.heartbeat_seconds(default 25s) so intermediary proxies/gateways do not close the idle connection; a failed heartbeat write also surfaces dead peers for prompt cleanup. Keep this interval below any upstream proxy idle-read timeout. - No Server Timeout:
WriteTimeoutis unset for the server so long-lived streams are not cut off; the server'sIdleTimeoutapplies only to idle keep-alive connections between requests, not to an active stream. - Automatic Cleanup: Server automatically cleans up disconnected clients
- Multi-User Support: Each user can have multiple concurrent SSE connections
- Cross-Origin Support: CORS headers are automatically set for browser compatibility
Event Stream Format
On connect the server emits an open frame, then a message frame per notification, and periodic keep-alive comments:
event: open
data: {"message": "Connection established"}
event: message
data: {"id":"123e4567-e89b-12d3-a456-426614174000","user_id":"...","customer_id":"...","event":"message_received","title":"New Message","content":"Hello from John","rec_type":"chats","link_url":"/chat/room/456","link_text":"View Message","read":false,"created_at":"2024-01-15T10:30:00Z","sent_at":"2024-01-15T10:30:00Z","channels_attempted":null,"channels_sent":null,"channels_delivered":null,"channels_failed":null}
: keepalive
event: close
data: {"reason": "server_shutdown"}
The four channels_* fields are null on SSE frames, not []: the payload builder constructs a
fresh notification and never sets those slices, and they carry no omitempty. The read routes
return the stored [].
The close frame is the shutdown signal. SSEManager.Shutdown flushes
event: close with {"reason": "server_shutdown"} to every connected client before cancelling
them, precisely so a client can back off rather than reconnect into a draining server.
Connection Lifecycle
- Connect: Client establishes SSE connection with authentication; server sends the
openframe - Register: Server registers client for the authenticated user (missed system notifications are replayed)
- Receive: Client receives real-time
messageframes as they occur, interleaved with: keepaliveheartbeats - Disconnect: Client closes connection or navigates away
- Cleanup: Server automatically removes client from active connections
Test Notification Endpoints
Trigger Test Notification (Queue-based)
POST /v1/notifications/trigger
Development/Testing Only - Trigger a test notification through the full notification system (queue-based processing).
Authentication: API Key required
Request Body
{
"type": "chats",
"event": "message_received",
"title_key": "notifications.chats.message_received.title",
"content": "This is a test notification",
"customer_id": "550e8400-e29b-41d4-a716-446655440000",
"user_ids": ["123e4567-e89b-12d3-a456-426614174000"],
"rec_type": "chats",
"rec_id": "789e4567-e89b-12d3-a456-426614174000"
}There is no title field on this body — the struct declares title_key, resolved at read time.
Five things about these two routes:
- Nothing is validated. No struct validator runs and no field is checked, so an empty body decodes successfully and is processed.
typeis a record type name, looked up inrbac.rec_types— not a kind of notification. It is consulted only whenrec_typeandrec_idare not both supplied.rec_idis honoured only whenrec_typeis supplied alongside it. Otherwise it is discarded:prepareNotificationFromInputreuses one variable for both purposes, so the storedrec_idbecomes the id of the record type looked up fromtype, not the record the notification is about. A row written that way can never be cleared byMarkUserNotificationsReadByRec, which matches onuser_id+rec_id+rec_type— its badge will not go away when the user opens the record.user_idsis unioned with every connected SSE client, not used instead of them. Supplying it does not stop connected users receiving the notification. With no connected clients and nouser_ids, the route answers200 {"message": "No connected users to notify"}having done nothing.is_systemis decoded and then dropped.prepareNotificationFromInputdoes not copy it into the notification it builds, so it has no effect on either route.
An unknown type answers 500, not 404. The service builds the error WithCode(404), but
the handler routes it through the deprecated HandleAppError, which switches on
Params["errorCode"] rather than on Code. Nothing sets that param, so the switch falls to
default and writes a 500 carrying common.record_not_found — a 404 in disguise.
Response
{
"message": "Notification sent to all connected users"
}Send Direct SSE Notification
POST /v1/notifications/sse
Development/Testing Only - Send a notification directly to SSE clients (bypasses queue processing).
Authentication: API Key required
Request Body
Same as trigger endpoint above.
Response
{
"message": "Notification sent to all connected users"
}Errors
The envelope
{"status": 404, "message": "Notification not found", "code": "notifications_m.not_found", "class": "business"}message is the resolved i18n template for code. code, class, retryable and details are
stamped only outside the 2xx range. details is never populated by this module — nothing under
modules/notifications calls WithDetails. ClassForStatus has no empty branch: 400 and 422
are validation; 408, 429, 502, 503 and 504 are temporary and additionally carry
retryable: true; everything else, 500 included, is business.
Three responses on these routes are not the envelope at all: the two API-key test routes and
the delete return a bare {"message": "..."} map with no status field; the SSE stream is
text/event-stream; and the SSE shutdown refusal is text/plain.
Codes these routes can produce
| Status | Code | When |
|---|---|---|
400 | notifications_m.invalid_id | notification_id did not parse as a UUID |
400 | notifications_m.invalid_user_id | No user id in the request context — SSE and delete |
400 | notifications_m.failed_to_update | The mark-read body did not decode. Names an update that was never attempted |
400 | common.invalid_input | customer_id did not parse, or limit/offset was not an integer. The customer_id case loses its specific code because the handler discards the service's AppError |
400 | common.failed_to_serialize | The body of a test route did not decode |
400 | query_m.invalid_sort_field | sort named a field outside the filterable list. Raised on the fetch pass only, so it does not fire when the count came back zero |
400 | query_m.invalid_field | stack named a field outside the filterable list. Fetch pass only, same as sort |
400 | query_m.invalid_format | stack left its [format] bracket unterminated. Fetch pass only |
404 | notifications_m.not_found | Read-one found no row. Raised after the storage read succeeds, by checking whether the returned row has the nil id — so a row the caller may not see is reported as absent rather than forbidden |
500 | common.record_not_found | A 404 in disguise on the two test routes: type named a record type rbac.rec_types does not have. See the test-endpoint section for why the status is wrong |
500 | query_m.invalid_search_field | A 400 in disguise on the list: search named a field outside the filterable list. The shared validator omits WithCode, and an empty Code sends HandleAppErrorWithCode to InternalServerError500 |
500 | common.database_error | Any storage failure on the list, the read-one, the mark-read or the delete. The storage layer builds it without WithCode, so the empty Code routes to InternalServerError500. On the delete this is also where a row that does not exist lands |
500 | common.rbac_failed_to_check_permission | The RBAC read-scope lookup failed on the list. GetPermittedRecordsCtx stamps WithCode(500) explicitly |
500 | common.server_error | The send failed on a test route |
The notifications_m.* constants also include failed_to_mark_as_read, failed_to_delete,
failed_to_clear, id_should_be_nil, db_not_initialized, queue_store_not_initialized,
service_init_failed, and the group/channel/event and preference families
(invalid_group, invalid_channel, invalid_event, failed_to_parse_group,
failed_to_parse_channel, failed_to_create_preference, failed_to_update_preferences,
failed_to_fetch_preferences, failed_to_fetch_notifications). None of the preference codes can
reach a client, because both preference handlers are stubs that never fail; the rest come from
the storage and processing layers rather than from these nine handlers directly.
Rate limiting
auth.RateLimitMiddleware is mounted on the root router, above every route in the service — but
only when the AppConfig flag rate_limits.rate_limits_switcher is true. reads that flag
once at bootstrap and calls r.Use inside the if, so with the flag off the middleware is absent
from the chain entirely — not mounted and idle — and no route can answer 429. Changing the
flag takes a restart. When it does fire the
429 code is not common.too_many_requests — that key is only the fallback the reply helper
stamps when called with no AppError. The codes that really arrive are rate_limits_m.exceeded,
rate_limits_m.global_exceeded and rate_limits_m.failed_to_increment_ip_limit, plus two free-text
strings that are not i18n keys at all: rate limit exceeded and Global rate limit exceeded.
Two different authentication chains
Seven routes use auth.WrapWithMiddlewares (bearer token). POST /v1/notifications/trigger and
POST /v1/notifications/sse use auth.APIKeyMiddleware instead — it replaces the chain rather
than wrapping it, so those two have no user context, no RBAC check and no licence check, and their
refusals are different:
| Status | Code | Cause |
|---|---|---|
401 | common.api_key_required | X-API-Key header absent or empty |
403 | common.invalid_api_key | The key is not in the loaded key set |
auth.APIKeyMiddleware has an OPTIONS bypass, but it is unreachable for the same reason as the
one on the SSE handler: auth.CORSMiddleware answers the preflight first when an Origin is
present, and chi answers 405 when it is not.
Their 503s differ from each other, too. POST /v1/notifications/trigger can answer the
graceful-shutdown map, because health.LifecycleMiddleware does not skip that path.
POST /v1/notifications/sse cannot answer 503 at all: the middleware skips
/v1/notifications/sse on the path alone, with no method test, so the shutdown map is out; and
auth.APIKeyMiddleware compares a header against the loaded key set and never calls
ensureCacheAvailable, so the auth-cache envelope is out as well. Its failures arrive as the
500 written by InternalServerError500.
Refusals the middleware writes before the handler runs — bearer routes
Produced by the root router's chain — health.LifecycleMiddleware first, then auth.Middleware —
not by module code.
| Status | Code | Cause |
|---|---|---|
401 | common.unauthorized | No bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup |
403 | common.rbac_no_rec_access → No access to the record | rbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code |
403 | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missing | The licence branch. module_not_licensed means the tenant's licence does not cover this module: its routes exist in the binary and are refused. license_key_missing is not a tenant problem — it means licenseService was nil, so the server came up without a usable COREBANQ_LICENSE_KEY and refuses every route until it is restarted. A fifth code, license_m.license_service_unavailable, is matched by the middleware but is written to the licence-error context by no code path, so it never reaches a client |
503 | {"overall_status": "unhealthy", …} — not the envelope | Graceful shutdown, and this one comes first. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler — except on the path /v1/notifications/sse, which the middleware skips. It skips on the path alone, with no method test, so both the GET and the POST on that path are exempt |
503 | auth_m.internal_server_error → Internal server error | The auth cache is unhealthy. ensureCacheAvailable runs before the blacklist lookup, so an unreachable Redis/valkey refuses every authenticated request. The code is the auth package's own MsgInternalServerError, not errs.MsgInternalServerError (common.server_error) |
The 503 is three different bodies in this module. While the server is draining,
health.LifecycleMiddleware writes a bare map — {"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} — with no status, code, class or retryable
field, and a fixed English message that Accept-Language does not translate. The auth-cache
503 is the envelope, but its code is auth_m.internal_server_error, not
common.server_error: ensureCacheAvailable calls errs.New(MsgInternalServerError)
unqualified from inside package auth, so the constant that resolves is the auth package's own.
Both render the same English message, so only the code distinguishes it from a genuine 500.
And on GET /v1/notifications/sse, which the shutdown middleware skips, a draining SSE manager
answers text/plain with the line server shutting down — while the auth-cache envelope still
reaches that route, because auth.Middleware runs in front of it either way.
Best Practices
Performance Guidelines
- Connection Limits: Implement per-user SSE connection limits
- Message Batching: Process notifications in batches for efficiency
- Queue Management: Monitor queue depths and processing rates
- Database Optimization: Use appropriate indexes for notification queries
Security Guidelines
- Authentication: Always verify user identity for notification access
- Data Sanitization: Sanitize notification content before delivery
- Audit Logging: Log all notification operations for security auditing
- Rate Limiting: Implement rate limits for notification creation
User Experience Guidelines
- Graceful Degradation: Handle SSE connection failures gracefully
- Progressive Enhancement: Provide fallback for users without SSE support
- Clear Messaging: Use clear, actionable notification content
- Preference Respect: Always honor user notification preferences
Dependencies
Required Services
- Database: PostgreSQL for notification storage
- Queue: Message queue system (local or AWS SQS)
- Storage: CoreBanq storage abstraction
- RBAC: Role-based access control system
External Integrations
- Email Service: For email notifications
- SMS Service: For text message notifications
- Authentication: User authentication and authorization
Configuration Dependencies
notifications.yaml: Notification configurationqueue.yaml: Queue system configuration- Database migration scripts for notification tables
The Notifications module provides a robust, scalable foundation for real-time user communication in the CoreBanq platform, with comprehensive features for multi-channel delivery, user preference management, and reliable message processing.