Domain 09: Notifications & Communication
Domain Owner: Backend (ASP.NET Core SignalR + FCM + OTP) / Mobile (Flutter SignalR client + Provider pattern) Last Updated: 2026-03-10 Workflows: WF-NOTIF-01 through WF-NOTIF-04
Domain Introduction
The Notifications & Communication domain handles all outbound messaging channels in the Almafrica platform: push notifications (FCM), SMS/OTP verification, real-time permission broadcasting (SignalR), and in-app sync status feedback. These channels serve different purposes -- push notifications alert users to stock expiry events, OTP codes verify phone numbers during client registration, SignalR delivers real-time permission changes to connected mobile clients, and sync completion events drive UI updates for data freshness indicators.
Key architectural principles:
- FCM push notifications are currently a stub: The
PushNotificationServiceinfrastructure exists with full interface contracts, device token management, and a backgroundExpiryAlertBackgroundService, but the mobile-sidePushNotificationServiceis a no-op. Firebase is not configured in the mobile project. - OTP is SMS-ready with a development bypass: The
OtpServicegenerates 6-digit codes with a 10-minute expiry and 5-attempt limit. In development mode, codes are logged rather than sent via SMS. The production SMS provider integration (Africa's Talking, Twilio, etc.) is a TODO placeholder. - SignalR is the real-time backbone: The
PermissionHubat/hubs/permissionsdeliversPermissionsUpdatedandDataChangedevents. Mobile clients maintain a persistent connection with exponential-backoff reconnection. - Sync events drive UI feedback:
SyncCompletionEventstreams fromSyncServiceflow through providers to update dashboard badges, snackbars, and offline indicator banners.
Domain Summary Diagram
graph LR
subgraph "Mobile Client"
PUSH_STUB[PushNotificationService - Stub]
NOTIF_PROV[NotificationProvider]
PERM_SYNC[PermissionSyncService]
REVOKE[PermissionRevokedHandler]
SYNC_SVC[SyncService]
CONN_MON[ConnectivityMonitor]
OFFLINE_IND[OfflineIndicatorWidget]
end
subgraph "Backend API"
PUSH_SVC[PushNotificationService]
OTP_CTRL[OtpController]
OTP_SVC[OtpService]
PERM_HUB[PermissionHub - SignalR]
PERM_BCAST[PermissionBroadcastService]
ROLE_SVC[RoleManagementService]
EXPIRY_BG[ExpiryAlertBackgroundService]
DEV_CTRL[DeviceController]
end
subgraph "External Services"
FCM[Firebase Cloud Messaging]
SMS_PROVIDER[SMS Provider - TBD]
end
subgraph "Storage"
DB_OTP[[OtpCodes Table]]
DB_NOTIF[[Notifications Table]]
DB_USER[[Users - FcmDeviceToken]]
SHARED_PREFS[[SharedPreferences]]
end
EXPIRY_BG -->|batch expiry check| PUSH_SVC
PUSH_SVC -->|HTTP POST FCM v1| FCM
FCM -.->|device delivery| PUSH_STUB
PUSH_STUB --> NOTIF_PROV
NOTIF_PROV -->|POST /api/device/token| DEV_CTRL
DEV_CTRL --> DB_USER
OTP_CTRL --> OTP_SVC
OTP_SVC --> DB_OTP
OTP_SVC -.->|TODO: SMS send| SMS_PROVIDER
ROLE_SVC --> PERM_BCAST
PERM_BCAST --> PERM_HUB
PERM_HUB -->|PermissionsUpdated| PERM_SYNC
PERM_HUB -->|DataChanged| PERM_SYNC
PERM_SYNC --> SHARED_PREFS
PERM_SYNC --> REVOKE
SYNC_SVC --> CONN_MON
SYNC_SVC --> OFFLINE_IND
EXPIRY_BG --> DB_NOTIF
Workflows
WF-NOTIF-01: Push Notification Flow (FCM)
Trigger: Backend event (currently: stock expiry background check)
Frequency: Daily scheduled run (configurable via ExpiryAlerts:RunAtUtcHour)
Offline Support: No (requires FCM connectivity; mobile stub is a no-op)
IMPORTANT -- STUB/PLACEHOLDER STATUS: The mobile-side
PushNotificationService(mobile/mon_jardin/lib/core/services/push_notification_service.dart) is explicitly a no-op stub. Firebase is not configured in the Flutter project. The backend infrastructure is fully implemented but theFirebaseOptions.Enabledflag defaults tofalse, causingSendToDeviceAsyncto log and return success without contacting FCM. The intended architecture is documented below.
Workflow Diagram
graph TD
A((ExpiryAlertBackgroundService\nDaily Timer)) --> B[Query active CollectionPoints]
B --> C[GetBatchesNearExpiryAsync\n7-day warning / 3-day critical]
C --> D{Batches near expiry?}
D -->|No| E[Skip - no alert needed]
D -->|Yes| F[GetAssignedAgentUserIdsAsync\nResolve agents for collection point]
F --> G{Any agents with auth users?}
G -->|No| H[Log debug - no recipients]
G -->|Yes| I[Deduplicate: filter already-notified\nvia Notifications table + 24h window]
I --> J{Unnotified batches remain?}
J -->|No| K[Skip - already alerted]
J -->|Yes| L[PushNotificationService.SendToUserAsync]
L --> M{FirebaseOptions.Enabled?}
M -->|No - CURRENT STATE| N[Log notification details\nReturn Success - no-op]
M -->|Yes - FUTURE| O[Lookup User.FcmDeviceToken\nfrom AlmafricaDbContext]
O --> P{Token exists?}
P -->|No| Q[Return Failure:\nno registered device token]
P -->|Yes| R[Build FCM v1 payload\nandroid.priority=high\napns.sound=default]
R --> S[HTTP POST to\nfcm.googleapis.com/v1/projects/PROJECT_ID/messages:send]
S --> T{HTTP 2xx?}
T -->|Yes| U[Log success]
T -->|No| V[Log warning with status + error body]
L --> W[Persist Notification entity\nUserId, Title, Message, ActionUrl]
W --> X[[Notifications Table]]
subgraph "Mobile - FUTURE"
Y((FCM delivers to device)) --> Z[PushNotificationService.onNotificationTap stream]
Z --> AA[NotificationProvider._handleNotificationTap]
AA --> AB[onNotificationTap callback\nNavigation to relevant screen]
end
subgraph "Device Token Registration"
AC((App login / token refresh)) --> AD[NotificationProvider.initialize]
AD --> AE[Read PushNotificationService.fcmToken]
AE --> AF{Token available?}
AF -->|Yes| AG[POST /api/device/token\nvia DeviceRemoteDataSource]
AG --> AH[DeviceController.UpdateDeviceToken]
AH --> AI[Store FcmDeviceToken + platform\non ApplicationUser]
AF -->|No - CURRENT STATE| AJ[No-op: stub returns null]
end
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Cron Trigger | ExpiryAlertBackgroundService | Fires daily at configured UTC hour (default 06:00). Implemented as BackgroundService with Task.Delay to next run time. | On unhandled exception: logs error, waits 5 minutes, retries loop. OperationCanceledException breaks cleanly. |
| 2 | Database Read | AlmafricaDbContext.CollectionPoints | Queries all active collection points for batch expiry scanning. | Standard EF Core exception handling; logs and continues to next point. |
| 3 | Service Call | IStockService.GetBatchesNearExpiryAsync | Fetches batches within 7-day (warning) or 3-day (critical) expiry threshold for a given collection point. | Result.IsFailure path logs warning with error detail and skips that collection point. |
| 4 | Database Read | GetAssignedAgentUserIdsAsync | Resolves assigned agent user IDs from Clients.AssignedAgentId, Farmers.ManagedById, and CollectionPoints.ProposedByAgentId, then maps to Agents.AuthUserId. | Returns empty list if no agents found; caller skips silently. |
| 5 | Deduplication | GetUnnotifiedBatchesAsync | Filters out batches already notified within 24-hour deduplication window by checking Notifications.ActionUrl against constructed alert keys. | Returns empty list on match; prevents duplicate push sends. |
| 6 | HTTP Request | PushNotificationService.SendToDeviceAsync | Posts FCM v1 JSON payload with token, notification, data, android (high priority, channel stock_loss_approval), and apns (sound, badge) fields. Currently a no-op when FirebaseOptions.Enabled is false. | Catches HttpRequestException: logs with status code and error body. Returns Result.Failure. |
| 7 | Database Write | Notification entity persist | Creates Notification row with UserId, Title, Message, and ActionUrl (formatted as expiry-alerts://collection-points/{id}/batches/{id}?type={type}&days={days}). | Standard SaveChangesAsync exception propagation. |
| 8 | HTTP Request | DeviceRemoteDataSourceImpl.updateDeviceToken | Mobile POSTs { deviceToken, platform } to POST /api/device/token. Currently unreachable because PushNotificationService.fcmToken is always null. | DioException caught and logged; returns false. NotificationProvider saves pending registration to SharedPreferences for retry. |
Data Transformations
- Input: Timer tick (daily schedule) +
StockBatchDtolist from stock service - Processing: Filter by expiry threshold, resolve agent recipients, deduplicate against 24h notification window, construct FCM payload with structured
datamap - Output: FCM HTTP POST (future) +
Notificationdatabase record
Error Handling
- FCM disabled (current): No-op return
Result.Success()-- silently succeeds, notification records are still persisted for audit - User has no FCM token: Returns
Result.Failure("User has no registered device token")-- alert background service logs warning and continues to next user - FCM HTTP error: Logs status code and response body, returns failure, skips persisting that user's notification entry
- Mobile token registration offline:
NotificationProvidercaches pending token + platform inSharedPreferences(pending_notification_fcm_token,pending_notification_fcm_platform) and retries on nextinitialize()call when connectivity is restored
Cross-References
- Triggers: Internal timer (no external WF trigger)
- Triggered by: Stock batch creation/update populates expiry dates used by threshold queries
- Related: WF-NOTIF-03 (SignalR
DataChangedevent may also fire after stock mutations)
WF-NOTIF-02: SMS/OTP Verification Flow
Trigger: User action requiring phone verification (client registration) Frequency: On-demand per registration Offline Support: No (requires network for both OTP send and verify)
NOTE -- DEVELOPMENT MODE: The
OtpServicecurrently operates in development mode by default (Otp:DevelopmentModeconfig defaults totrue). In dev mode, the bypass code000000is generated and logged to console rather than sent via SMS. The production SMS provider integration is a TODO comment in the code.
Workflow Diagram
graph TD
A((User initiates\nphone verification)) --> B[POST /api/otp/send\nAllowAnonymous]
B --> C[OtpController.SendOtp]
C --> D[OtpService.SendOtpAsync]
D --> E{Phone starts with +?}
E -->|No| F[Return false -\ninvalid format]
E -->|Yes| G[Invalidate existing OTPs\nfor same phone + purpose]
G --> H[[UPDATE OtpCodes\nSET IsUsed = true]]
H --> I{Development mode?}
I -->|Yes| J[Generate code = 000000\nLog to console]
I -->|No| K[Generate random 6-digit code]
K --> L[TODO: Send SMS via provider\nAfrica's Talking / Twilio / AWS SNS]
J --> M[Create OtpCode entity\nExpiresAt = now + 10 min\nAttemptCount = 0]
L --> M
M --> N[[INSERT INTO OtpCodes]]
N --> O[Return OtpResponse\nSuccess + mode indicator]
O --> P((User receives code\nvia SMS or logs))
P --> Q[POST /api/otp/verify\nAllowAnonymous]
Q --> R[OtpController.VerifyOtp]
R --> S[OtpService.VerifyOtpAsync]
S --> T[Query most recent valid OTP\nfor phone + purpose\nWHERE NOT IsUsed\nAND ExpiresAt > now]
T --> U{OTP record found?}
U -->|No| V[Return false -\nno valid OTP / expired]
U -->|Yes| W{AttemptCount >= 5?}
W -->|Yes| X[Mark OTP as used\nReturn false - max attempts]
W -->|No| Y[Increment AttemptCount]
Y --> Z{Code matches?}
Z -->|Yes| AA[Mark IsUsed = true\nReturn true]
Z -->|No| AB[Return false -\ninvalid code\nAttempt N/5]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Webhook Trigger | OtpController.SendOtp | POST /api/otp/send -- [AllowAnonymous]. Accepts SendOtpRequest(PhoneNumber, Purpose). Default purpose: "client_registration". | Returns 400 OtpResponse(Success: false) on service failure. |
| 2 | Code Node | OtpService.SendOtpAsync | Validates E.164 format, invalidates existing OTPs for same phone+purpose, generates 6-digit code (or 000000 in dev), persists OtpCode entity. | Wraps all logic in try-catch; logs and returns false on any exception. |
| 3 | Database Write | AlmafricaDbContext.OtpCodes | Invalidation: bulk update IsUsed = true on unexpired matching records. Creation: insert new OtpCode with 10-min expiry. | EF Core SaveChangesAsync -- exception propagates to outer catch. |
| 4 | External Service | SMS Provider (TODO) | Production path would call SendSmsAsync(phone, message). Currently a log statement placeholder. Intended providers: Africa's Talking, Twilio, AWS SNS. | Not yet implemented; will need retry logic and delivery status tracking. |
| 5 | Webhook Trigger | OtpController.VerifyOtp | POST /api/otp/verify -- [AllowAnonymous]. Accepts VerifyOtpRequest(PhoneNumber, Otp, Purpose). | Returns 400 OtpResponse(Success: false) with user-facing message on invalid/expired OTP. |
| 6 | Code Node | OtpService.VerifyOtpAsync | Queries most recent valid OTP ordered by CreatedAt DESC, checks attempt count (max 5), compares code, marks used on success. | Try-catch wrapper; logs and returns false on exception. |
| 7 | Utility Endpoint | OtpController.GetDevMode | GET /api/otp/dev-mode -- [AllowAnonymous]. Returns current development mode status for mobile UI messaging. | Simple synchronous check; no failure path. |
| 8 | Background Task | OtpService.CleanupExpiredOtpsAsync | Removes OTP records older than 7 days that are expired or used. Available for periodic cleanup scheduling. | Catches and logs exceptions; does not propagate. |
Data Transformations
- Input:
SendOtpRequest { PhoneNumber: "+243812345678", Purpose: "client_registration" } - Processing: Validate format, invalidate prior OTPs, generate code, persist with expiry metadata
- Output:
OtpResponse { Success: true, Message: "...", IsDevelopmentMode: true/false } - Verify Input:
VerifyOtpRequest { PhoneNumber: "+243812345678", Otp: "123456", Purpose: "client_registration" } - Verify Output:
OtpResponse { Success: true/false, Message: "..." }
Error Handling
- Invalid phone format:
SendOtpAsyncreturnsfalseimmediately if phone does not start with+ - Expired OTP:
VerifyOtpAsyncreturnsfalsewith log message; no valid OTP record found - Max attempts exceeded (5): OTP is marked as used and invalidated; user must request a new code
- Development mode safety:
IsDevelopmentModeflag is exposed in the send response and via a dedicated endpoint so mobile clients can adjust their UI messaging (e.g., "check server logs for code")
Cross-References
- Triggers: User-initiated from client registration flow on mobile
- Triggered by: N/A (user action only)
- Related: WF-AUTH-03 (OTP is part of the authentication/verification chain referenced in the auth domain)
WF-NOTIF-03: SignalR Real-Time Permission Broadcast
Trigger: Admin modifies user role assignments or role permission definitions Frequency: Event-driven (on role/permission change) Offline Support: Partial -- reconnects with exponential backoff when connectivity returns; permissions are cached locally
Workflow Diagram
graph TD
A((Admin changes\nuser roles or\nrole permissions)) --> B[RoleManagementService\nAssignRoleToUser / UpdateRolePermissions]
B --> C[Resolve updated EffectivePermissions\nfor affected user(s)]
C --> D{Single user\nor bulk?}
D -->|Single| E[BroadcastPermissionChangeAsync\nuserId + permission list]
D -->|Bulk - role update| F[BroadcastPermissionChangeToUsersAsync\npermissionsPerUser dictionary]
F --> G[Parallel Task.WhenAll\nfor each affected user]
G --> E
E --> H[IHubContext of PermissionHub\n.Clients.User userId\n.SendAsync PermissionsUpdated]
H --> I[Build PermissionUpdateMessage\nUserId + Permissions list + Timestamp]
I --> J[SignalR delivers to\nall user connections]
J --> K((Mobile: PermissionSyncService\n_handlePermissionsUpdated))
K --> L{Parse message format}
L -->|Map with Permissions key| M[Extract permission list\nfrom Map.Permissions]
L -->|Direct List - fallback| N[Cast directly to\nList of String]
M --> O[Get old permissions\nfrom PermissionStorage]
N --> O
O --> P[Store new permissions\nvia PermissionStorage.setPermissions]
P --> Q[Fire-and-forget:\n_refreshRolePermissions\nGET /api/auth/my-permissions-by-role]
Q --> R[Compare old vs new:\ndetect revocations]
R --> S{Permissions revoked?}
S -->|No| T[Done - UI reflects\nupdated permissions]
S -->|Yes| U[Emit PermissionRevocationEvent\nvia _revocationController stream]
U --> V[PermissionRevokedHandler\n_handleRevocationEvent]
V --> W{User on form screen?}
W -->|Yes| X[Set _logoutPending = true\nDefer until form navigation]
W -->|No| Y[Show snackbar:\nPermissions updated message]
Y --> Z[Clear auth data\nvia JwtAuthService]
Z --> AA[Navigate to login\npushNamedAndRemoveUntil]
subgraph "SignalR Connection Lifecycle"
AB((App startup / login)) --> AC[PermissionSyncService.connect]
AC --> AD{JWT token available?}
AD -->|No| AE[Stay disconnected]
AD -->|Yes| AF{Network connected?}
AF -->|No| AG[Listen for connectivity changes\nvia ConnectivityService.connectionStream]
AF -->|Yes| AH[HubConnectionBuilder\nwithUrl /hubs/permissions\nwithAutomaticReconnect]
AH --> AI[Register handlers:\nPermissionsUpdated\nDataChanged]
AI --> AJ[hubConnection.start]
AJ --> AK{Connected?}
AK -->|Yes| AL[State: connected\nReset reconnect attempts]
AK -->|No| AM[Schedule reconnect\nExponential backoff\n1s to 30s max]
end
subgraph "DataChanged Event"
AN((Backend: entity mutation)) --> AO[BroadcastDataChangedAsync\nentityType + entityId + action]
AO --> AP[HubContext.Clients.All\nSendAsync DataChanged]
AP --> AQ[Mobile: _handleDataChanged]
AQ --> AR[SyncManager.requestAutoPull\ntrigger: connectivity, force: true]
end
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | RoleManagementService | Role assignment (AssignRoleToUser) or role permission update (UpdateRolePermissions) in backend. Calls broadcast service after successful DB mutation. | Broadcast failures are logged but do not roll back the role change. |
| 2 | Code Node | PermissionBroadcastService.BroadcastPermissionChangeAsync | Validates userId is non-empty, constructs PermissionUpdateMessage { UserId, Permissions, Timestamp }, sends via IHubContext<PermissionHub>.Clients.User(userId).SendAsync("PermissionsUpdated", message). | Catches all exceptions; logs error; returns Result.Failure with error code InternalError. |
| 3 | Code Node | PermissionBroadcastService.BroadcastPermissionChangeToUsersAsync | Iterates user IDs with Task.WhenAll parallelism, calls single-user broadcast for each. Reports partial failure counts. | Logs warning if any user broadcasts fail; returns Result.Success even with partial failures. |
| 4 | Code Node | PermissionBroadcastService.BroadcastDataChangedAsync | Sends DataChangedMessage { EntityType, EntityId, Action, Timestamp } to Clients.All (all connected users). Used after entity mutations to trigger mobile sync pulls. | Catches exception; logs warning. Fire-and-forget pattern -- does not propagate errors. |
| 5 | SignalR Hub | PermissionHub | [Authorize(AuthenticationSchemes = "Bearer")]. Logs connect/disconnect events with ConnectionId and UserId. Exposes GetConnectionId() for client diagnostics. Mapped at /hubs/permissions. | Disconnect with error: logs as warning with exception. Clean disconnect: logs as info. |
| 6 | Code Node | PermissionSyncService._handlePermissionsUpdated | Parses PermissionUpdateMessage (handles both Map and List formats), updates PermissionStorage, triggers per-role permission refresh, detects revocations by diffing old vs new sets. | Wraps in try-catch; logs error. Malformed message types are logged and skipped. |
| 7 | Code Node | PermissionSyncService._refreshRolePermissions | Fire-and-forget Dio GET to /api/auth/my-permissions-by-role with current JWT. Parses rolePermissions map and stores via PermissionStorage.setRolePermissions. | .catchError logs failure. Does not block the main permission update flow. |
| 8 | Code Node | PermissionRevokedHandler._handleRevocationEvent | Checks _activeFormScreens set. If user is on a form, sets _logoutPending = true and defers. Otherwise executes immediate logout: show snackbar, clear auth data, navigate to login. | Missing ScaffoldMessenger or Navigator: logs and degrades gracefully. |
| 9 | Connection Manager | PermissionSyncService._establishConnection | Builds SignalR HubConnection with JWT token factory, automatic reconnect with exponential retry delays (1s to 30s over 10 attempts). Listens for onclose, onreconnecting, onreconnected. | Connection failure: schedules reconnect with exponential backoff. Network loss: subscribes to ConnectivityService.connectionStream for auto-reconnect on restore. |
Data Transformations
- Backend Input:
userId(string) +List<string>permission names (resolved fromEffectivePermissionsenum) - SignalR Payload:
PermissionUpdateMessage { UserId, Permissions: List<string>, Timestamp: DateTimeOffset } - Mobile Processing: Parse message, diff against
PermissionStorage.getPermissions(), store new set, emitPermissionRevocationEventif removals detected - DataChanged Payload:
DataChangedMessage { EntityType, EntityId, Action, Timestamp }-- consumed bySyncManagerto trigger immediate pull
Error Handling
- SignalR connection failure:
_scheduleReconnect()with exponential backoff (1s, 2s, 4s, 8s, 16s, 30s cap). Resets on successful reconnect. - Network loss during connection:
ConnectivityService.connectionStreamlistener triggers reconnect attempt when network is restored. - Permission revocation with active form:
PermissionRevokedHandlerdefers logout via_logoutPendingflag. Logout executes when form screen callsunregisterFormScreen(). Prevents data loss. - Broadcast to disconnected user: SignalR silently drops the message. User receives updated permissions on next login via the standard permission fetch flow.
- Partial bulk broadcast failure: Logged as warning with failed/total count. Unaffected users still receive their updates.
Cross-References
- Triggers: WF-AUTH-05 (Permission Sync referenced in auth domain -- this is the detailed implementation)
- Triggered by: Admin role management actions (RoleManagementService, BulkUserOperationsService)
- Feeds into: WF-NOTIF-04 (DataChanged events trigger sync which produces in-app notifications)
WF-NOTIF-04: In-App Sync Notifications
Trigger: Sync operation completion, connectivity state change, or SignalR DataChanged event Frequency: Event-driven (each sync cycle, connectivity transition, or server push) Offline Support: Yes -- offline indicator displays cached data status; sync events fire for local-only operations
Workflow Diagram
graph TD
A((Sync trigger sources)) --> B{Trigger type?}
B -->|SignalR DataChanged| C[PermissionSyncService._handleDataChanged]
C --> D[Invoke onDataChanged callback]
D --> E[SyncManager.requestAutoPull\ntrigger: connectivity, force: true]
B -->|Connectivity restored| F[ConnectivityService.connectionStream\nemits true]
F --> G[SyncManager heartbeat timer\ndetects online state]
G --> E
B -->|Manual sync| H[SyncCenterScreen\nuser taps Sync Now]
H --> E
B -->|Foreground heartbeat| I[SyncManager 2-minute timer]
I --> E
E --> J[SyncService executes\npull / push / full sync]
J --> K[_emitSyncCompletion]
K --> L[SyncCompletionEvent\nsuccess + entityCount +\nentityType + syncType +\ntimestamp + errorMessage]
L --> M[_syncCompletionController.add\nBroadcast stream]
M --> N((UI Listeners))
N --> O[Dashboard screens\nStreamBuilder on syncCompletionStream\nRefresh data counts]
N --> P[SyncCenterScreen\nUpdate pending/failed item lists\nShow sync timestamp]
subgraph "Connectivity UI Feedback"
Q((ConnectivityService\nstate change)) --> R[ConnectivityMonitor\nChangeNotifier]
R --> S[notifyListeners]
S --> T[OfflineIndicatorWidget\nbanner or chip mode]
T --> U{isConnected?}
U -->|Yes| V[Hide banner or show\nsubtle Online indicator]
U -->|No| W[Show Offline Mode banner\nwith pulse animation\nlast sync time\nrefresh button]
end
subgraph "Offline Queue Feedback"
X((Offline data creation)) --> Y[SyncQueueService\nenqueue item]
Y --> Z[SyncService.notifyLocalDataChanged]
Z --> AA[Emit SyncCompletionEvent\nentityType: local\nsyncType: push\nentityCount: 0]
AA --> AB[Dashboard refreshes\nlocal DB queries\nto show new offline record]
end
subgraph "Farmer Queue Notifications"
AC((Sync detects\nfarmer reassigned)) --> AD[SyncService emits\nFarmerRemovedFromQueueNotification]
AD --> AE[farmerRemovedStream\nbroadcast StreamController]
AE --> AF[UI listener shows\nsnackbar: farmer removed\nfrom your queue]
end
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | SyncManager | Orchestrates sync triggers: 2-minute foreground heartbeat, connectivity change listener, SignalR DataChanged callback, and manual sync requests. | Timer exceptions logged; loop continues. Connectivity subscription failure: logged. |
| 2 | Code Node | SyncService._emitSyncCompletion | Constructs SyncCompletionEvent { success, entityCount, entityType, syncType, timestamp, errorMessage } and adds to _syncCompletionController broadcast stream. | Checks _syncCompletionController.isClosed before emitting. |
| 3 | Code Node | SyncService.notifyLocalDataChanged | Emits a lightweight SyncCompletionEvent with entityType: "local", syncType: push, entityCount: 0 to signal UI screens that locally-created records should be visible. | No-op if stream is closed. |
| 4 | Provider | ConnectivityMonitor | Wraps ConnectivityService as a ChangeNotifier. Exposes isOnline, isOffline, lastStatusChange properties. Calls notifyListeners() on each state transition. | Subscription cancellation in dispose(). |
| 5 | Widget | OfflineIndicatorWidget | Displays banner (full-width) or chip (compact) based on ConnectivityService.isConnected. Banner mode shows: cloud icon, "Offline Mode" title, cached data message with last sync time, refresh button, optional dismiss. Includes pulse animation when offline. | Handles missing AppLocalizations with fallback strings. Cleans up animation controller and subscription in dispose(). |
| 6 | Widget | SyncCenterScreen | Dedicated sync management screen showing pending items, failed items, conflict list, and aggregate sync statistics. Listens to SyncService.syncCompletionStream and SyncQueueService for real-time updates. | Loading states managed via _isLoading and _isSyncing flags. Pagination support for large queues. |
| 7 | Stream | FarmerRemovedFromQueueNotification | Emitted when sync detects a farmer has been reassigned to another agent or approved and locked. Contains farmerId, farmerName, and reason. | Listeners handle via snackbar display with farmer name and reason context. |
Data Transformations
- Input: Sync operation results (success/failure counts, entity types, errors) OR connectivity state transitions (boolean)
- Processing:
SyncCompletionEventwraps sync results into a uniform event.ConnectivityMonitornormalizes connection state intoChangeNotifierpattern. - Output: UI updates -- dashboard count badges refresh, offline banners appear/disappear, sync center lists update, snackbars display for queue removals
Error Handling
- Sync failure during pull:
SyncCompletionEventcarriessuccess: falseanderrorMessage. Dashboard screens can display error state or retry prompts. - Connectivity flapping:
ConnectivityServiceuses DNS lookup + captive portal HTTP probe to verify genuine internet access (not just network interface). 60-second periodic recheck when offline prevents missing reconnection. - Stream controller closed: All emission points check
isClosedbefore adding events. Singleton services intentionally never close their broadcast controllers. - Offline queue data creation:
notifyLocalDataChanged()ensures screens listening tosyncCompletionStreamrefresh their local DB query results even though no network sync occurred -- newly-created offline records appear immediately.
Cross-References
- Triggers: WF-NOTIF-03 (SignalR
DataChangedevent triggers immediate sync pull) - Triggered by: All data sync workflows, connectivity state changes, user manual sync actions
- Related: WF-AUTH-05 (Permission sync also uses
PermissionSyncServiceconnection state), WF-AUTH-06 (Offline auth relies on sameConnectivityService)
Appendix: File Reference
Backend Files
| File | Purpose |
|---|---|
backend/Almafrica.API/Hubs/PermissionHub.cs | SignalR hub -- JWT-authorized, logs connect/disconnect, mapped at /hubs/permissions |
backend/Almafrica.API/Services/PermissionBroadcastService.cs | Sends PermissionsUpdated and DataChanged events via IHubContext<PermissionHub> |
backend/Almafrica.Application/Interfaces/IPermissionBroadcastService.cs | Interface for permission broadcast -- single user, bulk, and data-changed methods |
backend/Almafrica.Application/Interfaces/IPushNotificationService.cs | Interface for push notifications -- send to user, users, device, and token management |
backend/Almafrica.Infrastructure/Services/PushNotificationService.cs | FCM implementation with FirebaseOptions (currently disabled). Manages device tokens on ApplicationUser. |
backend/Almafrica.Infrastructure/Services/ExpiryAlertBackgroundService.cs | Daily background job scanning for near-expiry batches and sending push alerts |
backend/Almafrica.Application/Interfaces/IOtpService.cs | OTP generation and verification interface with development mode support |
backend/Almafrica.Infrastructure/Services/OtpService.cs | OTP implementation -- 6-digit codes, 10-min expiry, 5-attempt max, dev bypass 000000 |
backend/Almafrica.API/Controllers/OtpController.cs | OTP endpoints -- POST /api/otp/send, POST /api/otp/verify, GET /api/otp/dev-mode |
backend/Almafrica.API/Controllers/DeviceController.cs | Device token endpoints -- POST /api/device/token, DELETE /api/device/token |
backend/Almafrica.Application/DTOs/OtpDto.cs | DTOs: SendOtpRequest, VerifyOtpRequest, OtpResponse |
backend/Almafrica.Domain/Entities/OtpCode.cs | Entity: OTP code with phone, code, purpose, expiry, attempt count |
backend/Almafrica.Domain/Entities/Notification.cs | Entity: Notification with userId, title, message, isRead, actionUrl |
backend/Almafrica.Infrastructure/Services/RoleManagementService.cs | Calls PermissionBroadcastService after role assignment/update operations |
Mobile Files
| File | Purpose |
|---|---|
mobile/mon_jardin/lib/data/services/permission_sync_service.dart | SignalR client -- connects to /hubs/permissions, handles PermissionsUpdated and DataChanged, exponential-backoff reconnection |
mobile/mon_jardin/lib/core/auth/permission_revoked_handler.dart | Graceful logout on permission revocation -- defers if user is on a form screen |
mobile/mon_jardin/lib/core/services/push_notification_service.dart | STUB -- no-op placeholder. FCM token is always null. Preserves API surface for NotificationProvider. |
mobile/mon_jardin/lib/presentation/providers/notification_provider.dart | Manages FCM token registration lifecycle, pending registration retry, notification tap navigation |
mobile/mon_jardin/lib/data/datasources/remote/device_remote_datasource.dart | HTTP client for POST /api/device/token and DELETE /api/device/token |
mobile/mon_jardin/lib/data/services/connectivity_service.dart | Network monitoring with DNS + captive portal verification, connection stream, 60s offline recheck |
mobile/mon_jardin/lib/presentation/providers/connectivity_monitor.dart | ChangeNotifier wrapper around ConnectivityService for Provider pattern integration |
mobile/mon_jardin/lib/presentation/widgets/offline_indicator_widget.dart | Banner/chip widget for offline state display with pulse animation and refresh action |
mobile/mon_jardin/lib/data/services/sync_service.dart | Core sync service with SyncCompletionEvent stream and notifyLocalDataChanged() for UI refresh |
mobile/mon_jardin/lib/data/services/sync_manager.dart | Sync orchestrator -- heartbeat timer, DataChanged callback, auto-pull triggers |
mobile/mon_jardin/lib/data/services/sync/sync_progress.dart | SyncCompletionEvent and SyncType definitions |
mobile/mon_jardin/lib/presentation/sync_center/sync_center_screen.dart | Sync management UI -- pending items, failed items, conflicts, manual sync trigger |