In-App Notifications¶
Date: 2026-06-09
Problem / motivation¶
Operations needs a way to push announcements (maintenance windows, billing
notices, new-feature callouts) to tenants from the admin panel and have those
messages surface inside the tenant app — not as email. Today the only
admin-driven notification channel is the email log (sent_notifications); there
is no in-app message that a tenant user sees on login. This feature adds a
notification system controlled entirely from the admin panel: create, list,
view, target specific tenancies (or broadcast to all), and let the tenant app
read and mark them read.
In scope¶
- Master-DB
notificationstable holding the announcement (title, body, level, targeting, publish/expiry). - Master-DB
notification_readstable tracking per-user read state. - Admin GraphQL: create / update / delete / list / view notifications with tenancy targeting (specific tenant ids or global).
- Tenant GraphQL (read side):
myNotifications,myUnreadNotificationCount,markNotificationRead,markAllNotificationsRead. - Admin frontend: a Notifications page (list, compose dialog with tenant multi-select + level + global toggle, view, delete).
Out of scope¶
- Email delivery (already covered by
sent_notifications+ subscription reminders). - Push / websocket real-time delivery — the tenant app polls on load.
- Per-notification scheduling beyond a simple
expires_at(nostarts_atwindowing in v1). - RBAC menu gating for the tenant read endpoints — reading your own
notifications is ambient (like reading your own billing/profile), so no new
Path/Resourcemember or backfill is introduced. See Decisions.
Decisions¶
- No alembic migration. Notifications live in the master/public database
alongside
TenantandSentNotification. Master-schema models in this repo are created by the master bootstrap (Base.metadata.create_all), not by alembic — theSentNotificationmodel shipped in commitd48abfa2with no migration. This feature follows that exact precedent, which is why "no migration needed" holds. - Single source of truth in master, not fan-out. Because each tenant has its
own database (project-per-tenant), notifications could either be replicated
into every tenant DB (fan-out on write) or kept once in master and read
cross-DB. We keep them once in master so admin edits/deletes are instantly
consistent and there is no tenant-DB schema change. The tenant read resolvers
reach the master DB via
MasterTenantController.session_factory(resolved through the DI container). - Targeting is
is_global+ atarget_tenant_idsinteger array (FKs totenants.id), avoiding a join table.
Data model changes (master schema, app/core/db/)¶
- New enum
NotificationLevel(app/core/db/notification_level.py):INFO,SUCCESS,WARNING,CRITICAL(int-backed, likeSentNotificationType). - New model
Notification(app/core/db/tenant_model.py): id,title,body,level,is_global,target_tenant_ids(int[]),published,expires_at,created_at,updated_at.- New model
NotificationRead(app/core/db/tenant_model.py): id,notification_id(FK →notifications.id, cascade),tenant_id(FK →tenants.id, cascade),user_id(str — tenant-DB user UUID),read_at. Unique on(notification_id, user_id).
GraphQL surface¶
Admin (app/admin/graphql/notifications/)¶
type AdminNotification { id, title, body, level, isGlobal, targetTenantIds, published, expiresAt, createdAt, updatedAt }query notifications(published: Boolean, limit: Int = 100): [AdminNotification!]!query notification(id: Int!): AdminNotificationmutation createNotification(title, body, level, isGlobal, tenantIds, expiresAt, published): AdminNotificationmutation updateNotification(id, title?, body?, level?, isGlobal?, tenantIds?, expiresAt?, published?): AdminNotificationmutation deleteNotification(id: Int!): Boolean!
Tenant (app/graphql/notifications/)¶
type Notification { id, title, body, level, expiresAt, createdAt, read }query myNotifications(unreadOnly: Boolean = false): [Notification!]!query myUnreadNotificationCount: Int!mutation markNotificationRead(notificationId: Int!): Boolean!mutation markAllNotificationsRead: Boolean!
The tenant resolvers compute read per (notification, current user), and only
return notifications that are published, not expired, and either is_global
or whose target_tenant_ids contains the caller's tenant id.
Frontend contract (admin)¶
- New
Notificationspage in cifras-admin: lists notifications, shows level + target summary + published/expiry, compose dialog (title, body, level select, "all tenants" toggle, tenant multi-select with search), view dialog, delete. - New
notificationsAdminApi(create/update/delete/list/get). - New sidebar entry + route
/notifications. The existing/emailspage keeps its "Email Notifications" purpose; the email-log sidebar label is renamed to "Emails" to disambiguate.
Frontend contract (tenant app — consumed by the product team)¶
The tenant app should, on load, call myUnreadNotificationCount for a badge and
myNotifications for the list, render title/body styled by level, and call
markNotificationRead / markAllNotificationsRead as the user reads them.
What shipped¶
See the changelog entry for the released version. Tests cover the admin create mutation and the tenant read + mark-read path.
Future additions¶
- Real-time delivery (websocket/SSE) — deferred; polling is sufficient for announcements.
starts_atscheduling and recurring notifications — deferred until a concrete need.- Optional email mirror of an in-app notification — deferred.