Skip to content

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 notifications table holding the announcement (title, body, level, targeting, publish/expiry).
  • Master-DB notification_reads table 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 (no starts_at windowing 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/Resource member or backfill is introduced. See Decisions.

Decisions

  • No alembic migration. Notifications live in the master/public database alongside Tenant and SentNotification. Master-schema models in this repo are created by the master bootstrap (Base.metadata.create_all), not by alembic — the SentNotification model shipped in commit d48abfa2 with 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 + a target_tenant_ids integer array (FKs to tenants.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, like SentNotificationType).
  • 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!): AdminNotification
  • mutation createNotification(title, body, level, isGlobal, tenantIds, expiresAt, published): AdminNotification
  • mutation updateNotification(id, title?, body?, level?, isGlobal?, tenantIds?, expiresAt?, published?): AdminNotification
  • mutation 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 Notifications page 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 /emails page 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_at scheduling and recurring notifications — deferred until a concrete need.
  • Optional email mirror of an in-app notification — deferred.