Domain 03: Farmer Management
Domain Owner: Mobile (Flutter + SQLite + Dio) / Backend (ASP.NET Core + PostgreSQL + DigitalOcean Spaces) Last Updated: 2026-03-10 Workflows: WF-FARM-01 through WF-FARM-06
Domain Introduction
The Farmer Management domain is the foundational data-entry pipeline of the Almafrica platform. Field agents register smallholder farmers through a comprehensive 6-step mobile form, capturing personal details, GPS-tagged location, socio-economic indicators, land/plot characteristics, infrastructure access, and crop associations. Every operation is offline-first: farmer records are created in local SQLite before any network call, queued for background sync, and reconciled against the PostgreSQL backend when connectivity returns.
Key architectural principles:
- Offline-first local persistence: All farmer mutations (create, update, approve, reject) write to
FarmerLocalService(SQLite) first, then triggerSyncManager.requestSubmitSync()for deferred backend push. - Draft auto-save: The multi-step registration form auto-saves to
SharedPreferencesviaFarmerDraftService, preventing data loss from app kills or navigation away. - Approval workflow: Newly registered farmers enter
PendingApprovalstatus. Admins approve or reject. Rejection reasons flow back to the originating agent for correction and resubmission. - Chunked photo upload: Large farmer profile and crop planting photos use S3 multipart presigned URLs via
ChunkedUploadService, with per-part retry and pause/resume support. - Agent-scoped visibility: Agents see only their assigned farmers. Admins see all. Backend enforces scoping; mobile mirrors it via
FarmerLocalService.getFarmersByAgent(). - Background refresh: Read operations serve local data instantly, then fire-and-forget a background API call to refresh the cache when connected.
Domain Summary Diagram
graph LR
subgraph "Mobile Client"
REG((Agent: Add Farmer))
DRAFT[FarmerDraftService - SharedPreferences]
FORM[AddFarmerScreen - 6 Steps]
LOCAL[FarmerLocalService - SQLite]
SYNC[SyncManager / SyncService]
UPLOAD[ChunkedUploadService]
DETAILS[FarmerDetailsScreen]
end
subgraph "Backend API"
FC[FarmersController]
FVC[FarmerVisitsController]
FS[IFarmerService]
FAS[IFarmerApprovalService]
FAGS[IFarmerAgentService]
FCOS[IFarmerCropOfferService]
FVS[IFarmerVisitService]
FVAL[IFarmerValidationService]
MPUP[MultipartUploadController]
end
subgraph "Storage"
SQLITE[[SQLite - farmers / farmer_crops]]
PREFS[[SharedPreferences - draft]]
PG[[PostgreSQL - Farmers table]]
S3[[DigitalOcean Spaces - S3]]
end
REG --> FORM
FORM -->|auto-save| DRAFT
DRAFT --> PREFS
FORM -->|submit| LOCAL
LOCAL --> SQLITE
LOCAL -->|requestSubmitSync| SYNC
SYNC -->|POST /api/farmers| FC
FC --> FS
FS --> FVAL
FS --> PG
UPLOAD -->|initiate + parts + complete| MPUP
MPUP --> S3
DETAILS -->|local-first read| LOCAL
LOCAL -.->|background refresh| FC
FC -->|approve / reject| FAS
FC -->|reassign| FAGS
FC -->|crop-offers| FCOS
FVC -->|visits| FVS
Workflows
WF-FARM-01: Farmer Registration (Multi-Step)
Trigger: Agent taps "Add Farmer" from the agent home screen Frequency: On-demand (each farmer registration) Offline Support: Yes -- full offline creation with deferred sync
Workflow Diagram
graph TD
A((Agent taps Add Farmer)) --> B{Draft exists?}
B -->|Yes| C[FarmerDraftService.loadDraft]
C --> D[Show resume dialog with age]
D -->|Resume| E[Populate FarmerFormProvider from draft]
D -->|Discard| F[FarmerDraftService.clearDraft]
B -->|No| G[Initialize empty FarmerFormProvider]
F --> G
E --> H[Step 1: Personal Info]
G --> H
H --> I[Step 2: Location - Province / Territory / Village + GPS]
I --> J[Step 3: Socio-Economic - income / credit / cooperative]
J --> K[Step 4: Land & Plots - area / soil / ownership]
K --> L[Step 5: Infrastructure - water / electricity / tools]
L --> M[Step 6: Review & Submit]
H -.->|auto-save on change| N[FarmerDraftService.saveDraft]
I -.->|auto-save on change| N
J -.->|auto-save on change| N
K -.->|auto-save on change| N
L -.->|auto-save on change| N
N --> O[[SharedPreferences]]
M --> P{Validation passes?}
P -->|No| Q[Highlight steps with errors]
P -->|Yes| R[Build CreateFarmerRequest]
R --> S[FarmerService.createFarmer]
S --> T[LocalFarmer.create - UUID v4 assigned]
T --> U[FarmerLocalService.saveFarmer - SQLite]
U --> V[SyncManager.requestSubmitSync]
V --> W[FarmerDraftService.clearDraft]
W --> X[Navigate to farmer list]
V -.->|when online| Y[SyncService: POST /api/farmers]
Y --> Z{Backend validation?}
Z -->|Pass| AA[FarmerService.CreateFarmerAsync]
AA --> AB[FluentValidation + CreateValidator]
AB --> AC[[PostgreSQL: Insert Farmer]]
AC --> AD[Return 201 + FarmerDto with server ID]
Z -->|Fail 409| AE[Phone already registered]
Z -->|Fail 400| AF[Validation errors returned]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Manual Trigger | AddFarmerScreen | Agent initiates registration from home screen | -- |
| 2 | IF | FarmerDraftService.hasDraft() | Check SharedPreferences for unfinished draft | Returns false on exception |
| 3 | Function | FarmerDraftService.loadDraft() | Deserialize JSON from SharedPreferences, restore DateTime fields | Null on parse error |
| 4 | Function | FarmerDraftService.getFormattedDraftAge() | Show human-readable time since last save | Null on error |
| 5 | Set | FarmerFormProvider | State manager holding all 6 steps of form data, validation state | -- |
| 6 | Multi-Step Form | Step1PersonalInfo through Step6ReviewSubmit | Six form widgets in AddFarmerScreen stepper | Per-step validation with _stepsWithValidationErrors set |
| 7 | Function | FarmerDraftService.saveDraft() | Serialize form map to JSON in SharedPreferences with UUID draft ID | Returns false on failure |
| 8 | Function | FarmerService.createFarmer() | Build LocalFarmer.create(), assign UUID, save locally, trigger sync | Returns Result.failure() with message |
| 9 | Database | FarmerLocalService.saveFarmer() | Insert into SQLite farmers table with SyncStatus.pending | Duplicate phone check, merge-or-reject |
| 10 | Trigger | SyncManager.requestSubmitSync() | Debounced background sync request | Non-blocking, logs errors |
| 11 | HTTP Request | SyncService | POST /api/farmers with CreateFarmerRequest JSON body | Retry with exponential backoff; FK errors trigger master data refresh |
| 12 | Validation | FarmersController.CreateFarmer | FluentValidation via IValidator<CreateFarmerRequest> | Returns 400 with property-level error list |
| 13 | Database | IFarmerService.CreateFarmerAsync | EF Core insert into PostgreSQL Farmers table | 409 on duplicate phone/nationalId |
Data Transformations
- Form fields (TextEditingController values) --> Map<String, dynamic> (draft JSON) --> SharedPreferences (auto-save)
- Form fields --> CreateFarmerRequest (typed DTO) --> LocalFarmer.create() (with UUID, SyncStatus.pending) --> SQLite row
- SQLite row (on sync) --> JSON body --> POST /api/farmers --> PostgreSQL row (server-assigned GUID echoed back as
serverId) - Crops list: CreateFarmerCropRequest[] --> LocalFarmerCrop[] (UUID per crop) --> SQLite farmer_crops table
Error Handling
- App killed mid-form: Draft auto-saved to
SharedPreferences; on re-entry, user sees "Resume draft?" dialog with timestamp. - Offline at submit time: Farmer saved to SQLite with
SyncStatus.pending.SyncManagerretries on next connectivity event. - Duplicate phone (409): Backend returns conflict error.
SyncServicemarks farmer asSyncStatus.failed. Agent notified to correct. - FK constraint error (location IDs):
SyncService._refreshLocationMasterData()auto-refreshes provinces/territories/villages, then retries. - Validation error (400): Error messages mapped to form fields via
_extractErrorMessage()inFarmerService.
Cross-References
- Triggers: WF-FARM-02 (farmer enters PendingApproval queue)
- Triggered by: None (user-initiated)
- Related: WF-FARM-04 (crops can be added during registration step 4)
WF-FARM-02: Farmer Verification & Approval
Trigger: Admin reviews a farmer in the pending-approval list Frequency: On-demand (admin workflow) Offline Support: Yes -- approval/rejection saved locally first, synced when online
Workflow Diagram
graph TD
A((Admin opens Pending Approvals)) --> B[GET /api/farmers/pending-approval]
B --> C[FarmerService.getPendingApprovalFarmers]
C --> D[Display paginated farmer list]
D --> E{Admin decision}
E -->|Approve| F[FarmerService.approveFarmer - id]
F --> G[FarmerLocalService.getFarmer - by id]
G --> H[LocalFarmer.copyWith - approvalStatus: approved]
H --> I[FarmerLocalService.updateFarmer]
I --> J[SyncManager.requestSubmitSync]
J -.->|when online| K[POST /api/farmers/id/approve]
K --> L[FarmerApprovalService.ApproveFarmerAsync]
L --> M[[PostgreSQL: Update ApprovalStatus]]
E -->|Reject| N[Show rejection reason dialog]
N --> O{Reason provided?}
O -->|No| P[Show validation error]
O -->|Yes| Q[FarmerService.rejectFarmer - id + reason]
Q --> R[LocalFarmer.copyWith - approvalStatus: rejected + reason]
R --> S[FarmerLocalService.updateFarmer]
S --> T[SyncManager.requestSubmitSync]
T -.->|when online| U[POST /api/farmers/id/reject]
U --> V[FarmerApprovalService.RejectFarmerAsync]
V --> W[[PostgreSQL: Update ApprovalStatus + RejectionReason]]
W -.->|next sync| X[Agent pulls rejected list]
X --> Y[GET /api/farmers/my-rejected]
Y --> Z[Agent corrects and resubmits]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Manual Trigger | Admin UI | Admin navigates to pending approval screen | -- |
| 2 | HTTP Request | FarmerService.getPendingApprovalFarmers() | GET /api/farmers/pending-approval with pagination | Returns null on error; shows empty state |
| 3 | IF | Admin decision | User taps Approve or Reject button | -- |
| 4 | Function | FarmerService.approveFarmer() | Local-first: load farmer, set ApprovalStatus.approved, save, trigger sync | Returns Result.failure() if farmer not found |
| 5 | Database | FarmerLocalService.updateFarmer() | Update SQLite row with new approval status and SyncStatus.pending | -- |
| 6 | HTTP Request | POST /api/farmers/{id}/approve | Backend endpoint with [Permission(Permission.ApproveFarmers)] | 404 if farmer not found |
| 7 | Function | FarmerApprovalService.ApproveFarmerAsync() | Sets ApprovalStatus, records ReviewedById and ReviewedAt | Returns Result with error |
| 8 | Validation | Rejection reason check | rejectionReason.trim().isEmpty guard | Returns Result.failure('Rejection reason is required') |
| 9 | HTTP Request | POST /api/farmers/{id}/reject | Backend endpoint with [Permission(Permission.RejectFarmers)] + RejectFarmerRequest body | 400 if reason is empty |
| 10 | HTTP Request | GET /api/farmers/my-rejected | Agent retrieves their rejected farmers for correction | Returns null on error |
Data Transformations
- Admin action --> LocalFarmer.copyWith(approvalStatus, reviewedById, reviewedAt, syncStatus: pending) --> SQLite update
- SQLite row (on sync) --> POST /api/farmers/{id}/approve or POST /api/farmers/{id}/reject with
RejectFarmerRequest { RejectionReason }--> PostgreSQL update - Rejection feedback loop: Backend stores rejection reason --> Agent pulls via
GET /api/farmers/my-rejected--> Agent edits and resubmits (triggers WF-FARM-01 update path)
Error Handling
- Farmer not found locally:
FarmerService.approveFarmer()returnsResult.failure('Farmer not found locally for approval'). - Empty rejection reason: Client-side validation in
rejectFarmer()rejects empty/whitespace reasons before any write. - Offline approval: Stored locally with
SyncStatus.pending. The backend state will update on next sync cycle. - Concurrent edits:
SyncConflictServicehandles cases where the farmer is modified by another admin between local save and sync.
Cross-References
- Triggers: WF-FARM-01 (resubmission after rejection)
- Triggered by: WF-FARM-01 (new farmer enters pending queue)
WF-FARM-03: Farmer-Agent Assignment
Trigger: Admin reassigns a farmer to a different field agent Frequency: On-demand (admin operation) Offline Support: No (requires connectivity for reassignment)
Workflow Diagram
graph TD
A((Admin opens Farmer Details)) --> B[Select Reassign option]
B --> C[Show agent selection with target agent ID]
C --> D{Agent selected?}
D -->|No| E[Cancel]
D -->|Yes| F[PATCH /api/farmers/id/reassign]
F --> G[FarmersController.ReassignFarmer]
G --> H[FarmerAgentService.ReassignFarmerAsync]
H --> I{Farmer exists?}
I -->|No| J[Return 404 - Farmer not found]
I -->|Yes| K{New agent exists?}
K -->|No| L[Return 400 - Agent not found]
K -->|Yes| M[Update ManagedById in PostgreSQL]
M --> N[Record reassignment reason]
N --> O[Return updated FarmerDto]
O -.->|next sync cycle| P[Old agent: farmer removed from scope]
O -.->|next sync cycle| Q[New agent: farmer appears in scope]
P --> R[SyncService detects farmer no longer in scope]
R --> S[FarmerRemovedFromQueueNotification emitted]
S --> T[Agent sees snackbar notification]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Manual Trigger | Admin UI | Admin navigates to farmer details and selects reassign | -- |
| 2 | HTTP Request | PATCH /api/farmers/{id}/reassign | ReassignFarmerRequest { NewAgentId, Reason } | 404 farmer not found; 400 agent not found |
| 3 | Function | FarmersController.ReassignFarmer() | Delegates to IFarmerService.ReassignFarmerAsync() | Logs and returns appropriate HTTP status |
| 4 | Function | FarmerAgentService.ReassignFarmerAsync() | Updates ManagedById column, records reason | Returns Result<FarmerDto> |
| 5 | Database | PostgreSQL | UPDATE Farmers SET ManagedById = @newAgentId WHERE Id = @id | -- |
| 6 | Function | SyncService (old agent) | Detects farmer no longer in agent's scope during pull | Emits FarmerRemovedFromQueueNotification |
| 7 | Function | SyncService (new agent) | Pulls farmer into local SQLite on next sync | Standard sync error handling |
Data Transformations
- Admin input (target agent ID + reason) --> ReassignFarmerRequest --> PATCH /api/farmers/{id}/reassign
- PostgreSQL:
Farmers.ManagedByIdupdated from old agent to new agent - Old agent mobile: On next pull,
PullScopeResolverexcludes farmer from scope.SyncServicedetects the change and emitsFarmerRemovedFromQueueNotificationonfarmerRemovedStream. - New agent mobile: On next pull, farmer appears in their scoped farmer list.
Error Handling
- Farmer not found: Backend returns 404. UI shows error message.
- Agent not found: Backend validates new agent exists before update. Returns 400 if invalid.
- Pending edits on old agent: If the old agent has unsynced edits for this farmer,
SyncServicedetects the farmer is now managed by another agent and removes it from the sync queue, emitting a notification to inform the agent.
Cross-References
- Triggers: None directly
- Triggered by: Admin decision (manual)
- Related: WF-FARM-01 (new agent may need to update farmer data)
WF-FARM-04: Farmer Crop Association
Trigger: Agent adds a crop offer to a farmer's profile Frequency: On-demand (during or after registration) Offline Support: Partial -- initial crops saved during registration are offline-capable; standalone crop offers require connectivity
Workflow Diagram
graph TD
A((Agent opens Farmer Crop Offers)) --> B{Adding during registration?}
B -->|Yes - Step 4| C[Select crop from master data dropdown]
C --> D[Fill crop details: area, planting date, variety, yield]
D --> E[Add fertilizer / pesticide info]
E --> F[Capture planting photo]
F --> G[LocalFarmerCrop created with UUID]
G --> H[Saved alongside farmer in FarmerLocalService.saveFarmer]
H --> I[Synced with farmer in SyncService]
B -->|No - Standalone| J[GET /api/farmers/farmerId/crop-offers]
J --> K[Display existing crop offers]
K --> L[Agent taps Add Crop Offer]
L --> M[Select crop + fill offer details]
M --> N[Fill production source / supply frequency / volume]
N --> O[Set quality grade / organic status / delivery preference]
O --> P[Select priorities and common issues]
P --> Q[Optional photo upload]
Q --> R[POST /api/farmers/farmerId/crop-offers]
R --> S[FarmerCropOfferService.CreateFarmerCropOfferAsync]
S --> T{Farmer exists?}
T -->|No| U[Return 404]
T -->|Yes| V[[PostgreSQL: Insert FarmerCropOffer]]
V --> W[Link PriorityIds and CommonIssueIds]
W --> X[Return 201 + FarmerCropOfferDto]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Manual Trigger | Agent UI | Agent navigates to crop section of farmer profile | -- |
| 2 | IF | Registration vs standalone | During registration: crops bundled in CreateFarmerRequest.crops; standalone: separate API call | -- |
| 3 | Form | Step4LandPlots widget | Crop selection from MasterDataProvider dropdowns (category --> crop filter) | Empty dropdowns if master data not loaded |
| 4 | Function | LocalFarmerCrop creation | UUID assigned, linked to farmer via farmerId | -- |
| 5 | Database | FarmerLocalService.saveFarmer(farmer, crops: localCrops) | Batch insert farmer + crops in SQLite transaction | Rolls back on failure |
| 6 | HTTP Request | POST /api/farmers/{farmerId}/crop-offers | CreateFarmerCropOfferRequest with crop ID, volume, pricing, priorities | 404 farmer not found; 400 validation |
| 7 | Function | FarmerCropOfferService.CreateFarmerCropOfferAsync() | Creates offer with linked priority and issue configurations | Returns Result<FarmerCropOfferDto> |
| 8 | HTTP Request | POST /api/farmers/{farmerId}/crops/{cropId}/photo | Multipart file upload for planting photo | 400 no file; 404 farmer/crop not found |
Data Transformations
- Registration path: Form fields -->
CreateFarmerCropRequestlist -->LocalFarmerCropobjects --> SQLitefarmer_cropstable --> synced as part of farmer payload - Standalone path: Form fields -->
CreateFarmerCropOfferRequest-->POST /api/farmers/{farmerId}/crop-offers-->FarmerCropOfferentity with linkedPriorityIdsandCommonIssueIds--> PostgreSQL - Photo: Local file path -->
MultipartFile-->POST /api/farmers/{farmerId}/crops/{cropId}/photo--> DigitalOcean Spaces --> URL stored inplantingPhotoUrl
Error Handling
- Master data not loaded: Dropdowns show empty state.
_bootstrapMasterDataIfMissing()fetches from API if online, shows warning snackbar if offline. - Farmer not found: Backend returns 404 via
EnsureFarmerCanAccessCropOffersAsync()authorization check. - Photo upload failure: Photo URL stored as null; can be retried later via the farmer detail screen.
- Farmer role access check:
EnsureFarmerCanAccessCropOffersAsync()verifies that Farmer-role users can only access their own crop offers.
Cross-References
- Triggers: WF-FARM-06 (photo upload for crop)
- Triggered by: WF-FARM-01 (crops added during registration step 4)
WF-FARM-05: Farmer Visit Tracking
Trigger: Agent starts a field visit to a farmer Frequency: On-demand (each farmer visit) Offline Support: Partial -- visit start/end can be queued locally; GPS capture requires device location services
Workflow Diagram
graph TD
A((Agent starts visit)) --> B[Select farmer from local list]
B --> C[Capture GPS coordinates via device]
C --> D[Select visit purpose]
D --> E[Optional initial notes]
E --> F[POST /api/farmer-visits/start]
F --> G[FarmerVisitsController.StartVisit]
G --> H[FluentValidation: StartVisitRequest]
H --> I{Valid?}
I -->|No| J[Return 400 with field errors]
I -->|Yes| K[FarmerVisitService.StartVisitAsync]
K --> L[[PostgreSQL: Insert FarmerVisit - Status: InProgress]]
L --> M[Return 201 + FarmerVisitDetailDto]
M --> N[Agent conducts visit activities]
N --> O[Agent taps End Visit]
O --> P[Capture end GPS coordinates]
P --> Q[Add visit notes / observations]
Q --> R[PUT /api/farmer-visits/id/end]
R --> S[FarmerVisitsController.EndVisit]
S --> T[FluentValidation: EndVisitRequest]
T --> U{Valid?}
U -->|No| V[Return 400 with field errors]
U -->|Yes| W[FarmerVisitService.EndVisitAsync]
W --> X{Visit exists and InProgress?}
X -->|No| Y[Return 404]
X -->|Yes| Z[[PostgreSQL: Update EndedAt + Notes + Status: Completed]]
Z --> AA[Return 200 + FarmerVisitDetailDto with linked Productions]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Manual Trigger | Agent UI | Agent selects farmer and initiates visit | -- |
| 2 | Function | GPS capture | Device geolocation for Latitude / Longitude | Falls back to null if permission denied |
| 3 | Set | StartVisitRequest | FarmerId, AgentId, StartedAt, GPS, Purpose (enum: VisitPurpose) | -- |
| 4 | HTTP Request | POST /api/farmer-visits or POST /api/farmer-visits/start | Dual-route endpoint accepting StartVisitRequest | 400 validation errors |
| 5 | Validation | IValidator<StartVisitRequest> | FluentValidation rules for required fields | Returns property-level errors |
| 6 | Function | FarmerVisitService.StartVisitAsync() | Creates FarmerVisit entity with Status: InProgress | Returns Result<FarmerVisitDetailDto> |
| 7 | Database | PostgreSQL insert | INSERT INTO FarmerVisits with visit metadata | -- |
| 8 | HTTP Request | PUT /api/farmer-visits/{id}/end or POST /api/farmer-visits/{id}/end | EndVisitRequest with EndedAt, GPS, notes, VisitStatus | 404 if visit not found; 400 validation |
| 9 | Function | FarmerVisitService.EndVisitAsync() | Updates visit with end time, notes, status. Returns detail with linked Productions. | Not found returns 404 |
Data Transformations
- Start: Agent input -->
StartVisitRequest { FarmerId, AgentId, StartedAt, Latitude, Longitude, Purpose, Notes }--> PostgreSQLFarmerVisitsrow - End: Agent input -->
EndVisitRequest { EndedAt, Latitude, Longitude, Notes, Status }--> PostgreSQL update on existing visit row - Query:
FarmerVisitFilterDtowith pagination, date ranges, purpose/status filters -->PagedResult<FarmerVisitListDto> - Detail view:
FarmerVisitDetailDtoincludesIReadOnlyList<ProductionListDto>for linked production cycle records captured during the visit
Error Handling
- GPS unavailable: Latitude/Longitude sent as null. Visit still created without location data.
- Visit not found on end: Returns 404. UI shows error and allows retry.
- Double-start prevention:
FluentValidationand service layer can reject overlapping visits for the same agent/farmer combination. - Network failure on start: Visit creation requires connectivity. Agent should retry when connection is restored.
Cross-References
- Triggers: Production cycle capture workflows (visits link to production records)
- Triggered by: Agent field activity schedule
- Related: WF-FARM-01 (visits reference farmer ID)
WF-FARM-06: Farmer Photo Upload (Offline-Resilient)
Trigger: Photo captured during farmer registration or profile update Frequency: On-demand (each photo capture) Offline Support: Yes -- photos stored locally, upload queued and resumed when online
Workflow Diagram
graph TD
A((Photo captured via camera/gallery)) --> B[Save to device local storage]
B --> C{Connected?}
C -->|No| D[Store file path in LocalFarmer/LocalFarmerCrop]
D --> E[ChunkedUploadService: save state to SQLite]
E --> F[Status: pending - wait for connectivity]
F -.->|connectivity restored| G[SyncManager triggers pending upload check]
G --> H[ChunkedUploadService.getPendingUploads]
C -->|Yes| I[ChunkedUploadService.initiateUpload]
H --> I
I --> J[POST /api/multipartupload/initiate]
J --> K[Backend returns uploadId + presigned URLs + totalParts]
K --> L[Save ChunkedUploadState to SQLite]
L --> M[ChunkedUploadService.uploadParts]
M --> N{For each 512KB chunk}
N --> O[Read chunk bytes from file via RandomAccessFile]
O --> P[PUT chunk to S3 presigned URL]
P --> Q{Upload success?}
Q -->|Yes| R[Save ETag + mark part uploaded]
R --> S{More parts?}
S -->|Yes| N
S -->|No| T[All parts uploaded]
Q -->|No| U{Retry count < 3?}
U -->|Yes| V[Exponential backoff: 2^n seconds]
V --> P
U -->|No| W{Consecutive failures >= 10?}
W -->|Yes| X[Status: failed - abort]
W -->|No| Y{Still connected?}
Y -->|No| Z[Status: paused - save progress]
Y -->|Yes| V
T --> AA[POST /api/multipartupload/complete]
AA --> AB[Backend merges parts in S3]
AB --> AC[Return final file URL]
AC --> AD[Update farmer/crop record with photo URL]
AD --> AE[Status: completed]
AE --> AF[ChunkedUploadService.cleanupOldUploads - 7 day retention]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Manual Trigger | Camera / image picker | ImagePicker captures photo from camera or gallery | -- |
| 2 | Function | Local file storage | Photo saved to device filesystem | -- |
| 3 | IF | ConnectivityService.instance.isConnected | Check network state | -- |
| 4 | Database | SQLite chunked_uploads table | Persist ChunkedUploadState for resume capability | -- |
| 5 | HTTP Request | POST /api/multipartupload/initiate | InitiateMultipartUploadRequest { fileName, contentType, totalFileSize, folder, chunkSizeBytes } | Exception on non-200 |
| 6 | Function | ChunkedUploadService.uploadParts() | Iterates pending parts, reads 512KB chunks via RandomAccessFile, uploads to presigned URLs | Per-part retry (3 attempts) with exponential backoff |
| 7 | HTTP Request | PUT to S3 presigned URL | Raw bytes with Content-Type: application/octet-stream | Retries with Duration(seconds: pow(2, retries)) |
| 8 | Function | ETag extraction | Parse etag header from S3 response, strip quotes | Exception if no ETag |
| 9 | HTTP Request | POST /api/multipartupload/complete | CompleteMultipartUploadRequest { uploadId, objectKey, completedParts[] } | Exception on non-200 |
| 10 | Function | URL update | Final S3 URL written to farmer or crop record | -- |
| 11 | Cron/Scheduled | cleanupOldUploads() | Delete completed/aborted records older than 7 days from SQLite | Logged but non-blocking |
Data Transformations
- Photo file --> 512KB chunks (via
RandomAccessFileseek + read) --> S3 presigned URL PUT (per chunk) - Initiate response:
{ uploadId, objectKey, totalParts, chunkSizeBytes, partUrls[{ partNumber, url, expiresAt }] }-->ChunkedUploadState+ChunkedUploadPart[]--> SQLite - Complete request:
{ uploadId, objectKey, completedParts[{ partNumber, etag }] }--> Backend merges S3 parts --> final URL returned - Content type detection: File extension --> MIME type mapping (jpg/png/gif/webp/pdf/doc/docx) in
_getContentType()
Error Handling
- Network lost mid-upload:
ChunkedUploadServicedetects viaConnectivity().checkConnectivity(), sets status topaused, saves progress. Already-uploaded parts are preserved viaisUploadedflag and ETag storage. - Presigned URLs expired: State marked as
failedwith message. Upload must be re-initiated from scratch (new presigned URLs required). - Source file deleted: If the photo file no longer exists on disk, upload is marked
failedwith message. - Max consecutive failures (10): Upload aborted to prevent infinite retry loops. Can be manually retried via
resumeUpload(). - Single part failure: Up to 3 retries with exponential backoff (2s, 4s, 8s). Consecutive failure counter resets on any successful part.
- Cleanup:
cleanupOldUploads()purges completed/aborted records older than 7 days to prevent SQLite bloat. - Profile photo direct upload: For simple cases,
FarmerService.uploadProfilePhoto()uses standardMultipartFileupload toPOST /api/farmers/{id}/photowithout chunking. Chunked upload is used for larger files or unreliable connections.
Cross-References
- Triggers: None
- Triggered by: WF-FARM-01 (profile photo during registration), WF-FARM-04 (planting photo for crop)
- Related: Backend
MultipartUploadControllerhandles the S3 lifecycle
Appendix: Key File Reference
Mobile (Flutter)
| File | Purpose |
|---|---|
mobile/mon_jardin/lib/presentation/add_farmer/add_farmer_screen.dart | 6-step farmer registration screen with stepper, draft resume, and form provider |
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step1_personal_info.dart | Step 1: name, DOB, gender, national ID, phone |
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step2_location.dart | Step 2: province, territory, village, GPS coordinates |
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step3_socio_economic.dart | Step 3: income, credit, savings, cooperative membership |
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step4_land_plots.dart | Step 4: land area, soil type, ownership, crop associations |
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step5_infrastructure.dart | Step 5: water, electricity, irrigation, tools, storage, transport |
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step6_review_submit.dart | Step 6: summary review and submit |
mobile/mon_jardin/lib/presentation/farmer_details/farmer_details_screen.dart | Farmer detail view with assessments, sync status, edit actions |
mobile/mon_jardin/lib/data/services/farmer_service.dart | Farmer CRUD operations: local-first create/update, approval, photo upload |
mobile/mon_jardin/lib/data/services/farmer_draft_service.dart | Auto-save/load/clear draft via SharedPreferences with UUID tracking |
mobile/mon_jardin/lib/data/local/farmer_local_service.dart | SQLite CRUD for LocalFarmer with phone dedup and sync status management |
mobile/mon_jardin/lib/data/services/sync_service.dart | Full sync engine: push pending farmers, pull scoped farmers, conflict resolution |
mobile/mon_jardin/lib/data/services/sync_manager.dart | Debounced sync orchestrator: requestSubmitSync() triggers deferred push |
mobile/mon_jardin/lib/data/services/chunked_upload_service.dart | S3 multipart upload: 512KB chunks, presigned URLs, retry/resume, SQLite state |
mobile/mon_jardin/lib/data/services/connectivity_service.dart | Network state monitoring for offline-first decisions |
Backend (ASP.NET Core)
| File | Purpose |
|---|---|
backend/Almafrica.API/Controllers/FarmersController.cs | REST endpoints: CRUD, approval, assignment, bulk import/export, crop offers, photo upload |
backend/Almafrica.API/Controllers/FarmerVisitsController.cs | REST endpoints: start visit, end visit, list/filter visits |
backend/Almafrica.Application/Interfaces/IFarmerService.cs | Service contract: create, read, update, delete, approve, reject, reassign, bulk, export |
backend/Almafrica.Application/Interfaces/IFarmerApprovalService.cs | Approval contract: approve, reject, set active status |
backend/Almafrica.Application/Interfaces/IFarmerAgentService.cs | Agent assignment contract: get by agent, reassign, lookup by auth user |
backend/Almafrica.Application/Interfaces/IFarmerCropOfferService.cs | Crop offer CRUD contract |
backend/Almafrica.Application/Interfaces/IFarmerVisitService.cs | Visit contract: start, end, list with filters |
backend/Almafrica.Application/Interfaces/IFarmerValidationService.cs | Validation service contract |
backend/Almafrica.Application/DTOs/FarmerDto.cs | Farmer DTO with full field set |
backend/Almafrica.Application/DTOs/FarmerCropOfferDto.cs | Crop offer DTOs: create/update requests, response with priorities and issues |
backend/Almafrica.Application/DTOs/FarmerVisitDto.cs | Visit DTOs: list/detail views, start/end requests, filter |
backend/Almafrica.Application/DTOs/FarmerSummaryDto.cs | Lightweight farmer summary for list views |
backend/Almafrica.Infrastructure/Services/Farmer/FarmerApprovalService.cs | Approval service implementation |
backend/Almafrica.Infrastructure/Services/Farmer/FarmerAgentService.cs | Agent assignment service implementation |
backend/Almafrica.Infrastructure/Services/Farmer/FarmerCropOfferService.cs | Crop offer service implementation |
backend/Almafrica.Infrastructure/Services/Farmer/FarmerValidationService.cs | Farmer validation service implementation |
backend/Almafrica.Infrastructure/Services/Farmer/FarmerBulkService.cs | Bulk import/export service implementation |