Skip to main content

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 PushNotificationService infrastructure exists with full interface contracts, device token management, and a background ExpiryAlertBackgroundService, but the mobile-side PushNotificationService is a no-op. Firebase is not configured in the mobile project.
  • OTP is SMS-ready with a development bypass: The OtpService generates 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 PermissionHub at /hubs/permissions delivers PermissionsUpdated and DataChanged events. Mobile clients maintain a persistent connection with exponential-backoff reconnection.
  • Sync events drive UI feedback: SyncCompletionEvent streams from SyncService flow 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 the FirebaseOptions.Enabled flag defaults to false, causing SendToDeviceAsync to 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 TypeComponentFunctionError Handling
1Cron TriggerExpiryAlertBackgroundServiceFires 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.
2Database ReadAlmafricaDbContext.CollectionPointsQueries all active collection points for batch expiry scanning.Standard EF Core exception handling; logs and continues to next point.
3Service CallIStockService.GetBatchesNearExpiryAsyncFetches 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.
4Database ReadGetAssignedAgentUserIdsAsyncResolves 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.
5DeduplicationGetUnnotifiedBatchesAsyncFilters 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.
6HTTP RequestPushNotificationService.SendToDeviceAsyncPosts 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.
7Database WriteNotification entity persistCreates 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.
8HTTP RequestDeviceRemoteDataSourceImpl.updateDeviceTokenMobile 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) + StockBatchDto list from stock service
  • Processing: Filter by expiry threshold, resolve agent recipients, deduplicate against 24h notification window, construct FCM payload with structured data map
  • Output: FCM HTTP POST (future) + Notification database 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: NotificationProvider caches pending token + platform in SharedPreferences (pending_notification_fcm_token, pending_notification_fcm_platform) and retries on next initialize() 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 DataChanged event 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 OtpService currently operates in development mode by default (Otp:DevelopmentMode config defaults to true). In dev mode, the bypass code 000000 is 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 TypeComponentFunctionError Handling
1Webhook TriggerOtpController.SendOtpPOST /api/otp/send -- [AllowAnonymous]. Accepts SendOtpRequest(PhoneNumber, Purpose). Default purpose: "client_registration".Returns 400 OtpResponse(Success: false) on service failure.
2Code NodeOtpService.SendOtpAsyncValidates 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.
3Database WriteAlmafricaDbContext.OtpCodesInvalidation: bulk update IsUsed = true on unexpired matching records. Creation: insert new OtpCode with 10-min expiry.EF Core SaveChangesAsync -- exception propagates to outer catch.
4External ServiceSMS 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.
5Webhook TriggerOtpController.VerifyOtpPOST /api/otp/verify -- [AllowAnonymous]. Accepts VerifyOtpRequest(PhoneNumber, Otp, Purpose).Returns 400 OtpResponse(Success: false) with user-facing message on invalid/expired OTP.
6Code NodeOtpService.VerifyOtpAsyncQueries 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.
7Utility EndpointOtpController.GetDevModeGET /api/otp/dev-mode -- [AllowAnonymous]. Returns current development mode status for mobile UI messaging.Simple synchronous check; no failure path.
8Background TaskOtpService.CleanupExpiredOtpsAsyncRemoves 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: SendOtpAsync returns false immediately if phone does not start with +
  • Expired OTP: VerifyOtpAsync returns false with 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: IsDevelopmentMode flag 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 TypeComponentFunctionError Handling
1TriggerRoleManagementServiceRole 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.
2Code NodePermissionBroadcastService.BroadcastPermissionChangeAsyncValidates 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.
3Code NodePermissionBroadcastService.BroadcastPermissionChangeToUsersAsyncIterates 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.
4Code NodePermissionBroadcastService.BroadcastDataChangedAsyncSends 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.
5SignalR HubPermissionHub[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.
6Code NodePermissionSyncService._handlePermissionsUpdatedParses 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.
7Code NodePermissionSyncService._refreshRolePermissionsFire-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.
8Code NodePermissionRevokedHandler._handleRevocationEventChecks _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.
9Connection ManagerPermissionSyncService._establishConnectionBuilds 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 from EffectivePermissions enum)
  • SignalR Payload: PermissionUpdateMessage { UserId, Permissions: List<string>, Timestamp: DateTimeOffset }
  • Mobile Processing: Parse message, diff against PermissionStorage.getPermissions(), store new set, emit PermissionRevocationEvent if removals detected
  • DataChanged Payload: DataChangedMessage { EntityType, EntityId, Action, Timestamp } -- consumed by SyncManager to 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.connectionStream listener triggers reconnect attempt when network is restored.
  • Permission revocation with active form: PermissionRevokedHandler defers logout via _logoutPending flag. Logout executes when form screen calls unregisterFormScreen(). 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 TypeComponentFunctionError Handling
1TriggerSyncManagerOrchestrates 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.
2Code NodeSyncService._emitSyncCompletionConstructs SyncCompletionEvent { success, entityCount, entityType, syncType, timestamp, errorMessage } and adds to _syncCompletionController broadcast stream.Checks _syncCompletionController.isClosed before emitting.
3Code NodeSyncService.notifyLocalDataChangedEmits 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.
4ProviderConnectivityMonitorWraps ConnectivityService as a ChangeNotifier. Exposes isOnline, isOffline, lastStatusChange properties. Calls notifyListeners() on each state transition.Subscription cancellation in dispose().
5WidgetOfflineIndicatorWidgetDisplays 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().
6WidgetSyncCenterScreenDedicated 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.
7StreamFarmerRemovedFromQueueNotificationEmitted 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: SyncCompletionEvent wraps sync results into a uniform event. ConnectivityMonitor normalizes connection state into ChangeNotifier pattern.
  • 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: SyncCompletionEvent carries success: false and errorMessage. Dashboard screens can display error state or retry prompts.
  • Connectivity flapping: ConnectivityService uses 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 isClosed before adding events. Singleton services intentionally never close their broadcast controllers.
  • Offline queue data creation: notifyLocalDataChanged() ensures screens listening to syncCompletionStream refresh their local DB query results even though no network sync occurred -- newly-created offline records appear immediately.

Cross-References

  • Triggers: WF-NOTIF-03 (SignalR DataChanged event 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 PermissionSyncService connection state), WF-AUTH-06 (Offline auth relies on same ConnectivityService)

Appendix: File Reference

Backend Files

FilePurpose
backend/Almafrica.API/Hubs/PermissionHub.csSignalR hub -- JWT-authorized, logs connect/disconnect, mapped at /hubs/permissions
backend/Almafrica.API/Services/PermissionBroadcastService.csSends PermissionsUpdated and DataChanged events via IHubContext<PermissionHub>
backend/Almafrica.Application/Interfaces/IPermissionBroadcastService.csInterface for permission broadcast -- single user, bulk, and data-changed methods
backend/Almafrica.Application/Interfaces/IPushNotificationService.csInterface for push notifications -- send to user, users, device, and token management
backend/Almafrica.Infrastructure/Services/PushNotificationService.csFCM implementation with FirebaseOptions (currently disabled). Manages device tokens on ApplicationUser.
backend/Almafrica.Infrastructure/Services/ExpiryAlertBackgroundService.csDaily background job scanning for near-expiry batches and sending push alerts
backend/Almafrica.Application/Interfaces/IOtpService.csOTP generation and verification interface with development mode support
backend/Almafrica.Infrastructure/Services/OtpService.csOTP implementation -- 6-digit codes, 10-min expiry, 5-attempt max, dev bypass 000000
backend/Almafrica.API/Controllers/OtpController.csOTP endpoints -- POST /api/otp/send, POST /api/otp/verify, GET /api/otp/dev-mode
backend/Almafrica.API/Controllers/DeviceController.csDevice token endpoints -- POST /api/device/token, DELETE /api/device/token
backend/Almafrica.Application/DTOs/OtpDto.csDTOs: SendOtpRequest, VerifyOtpRequest, OtpResponse
backend/Almafrica.Domain/Entities/OtpCode.csEntity: OTP code with phone, code, purpose, expiry, attempt count
backend/Almafrica.Domain/Entities/Notification.csEntity: Notification with userId, title, message, isRead, actionUrl
backend/Almafrica.Infrastructure/Services/RoleManagementService.csCalls PermissionBroadcastService after role assignment/update operations

Mobile Files

FilePurpose
mobile/mon_jardin/lib/data/services/permission_sync_service.dartSignalR client -- connects to /hubs/permissions, handles PermissionsUpdated and DataChanged, exponential-backoff reconnection
mobile/mon_jardin/lib/core/auth/permission_revoked_handler.dartGraceful logout on permission revocation -- defers if user is on a form screen
mobile/mon_jardin/lib/core/services/push_notification_service.dartSTUB -- no-op placeholder. FCM token is always null. Preserves API surface for NotificationProvider.
mobile/mon_jardin/lib/presentation/providers/notification_provider.dartManages FCM token registration lifecycle, pending registration retry, notification tap navigation
mobile/mon_jardin/lib/data/datasources/remote/device_remote_datasource.dartHTTP client for POST /api/device/token and DELETE /api/device/token
mobile/mon_jardin/lib/data/services/connectivity_service.dartNetwork monitoring with DNS + captive portal verification, connection stream, 60s offline recheck
mobile/mon_jardin/lib/presentation/providers/connectivity_monitor.dartChangeNotifier wrapper around ConnectivityService for Provider pattern integration
mobile/mon_jardin/lib/presentation/widgets/offline_indicator_widget.dartBanner/chip widget for offline state display with pulse animation and refresh action
mobile/mon_jardin/lib/data/services/sync_service.dartCore sync service with SyncCompletionEvent stream and notifyLocalDataChanged() for UI refresh
mobile/mon_jardin/lib/data/services/sync_manager.dartSync orchestrator -- heartbeat timer, DataChanged callback, auto-pull triggers
mobile/mon_jardin/lib/data/services/sync/sync_progress.dartSyncCompletionEvent and SyncType definitions
mobile/mon_jardin/lib/presentation/sync_center/sync_center_screen.dartSync management UI -- pending items, failed items, conflicts, manual sync trigger