Skip to main content

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 trigger SyncManager.requestSubmitSync() for deferred backend push.
  • Draft auto-save: The multi-step registration form auto-saves to SharedPreferences via FarmerDraftService, preventing data loss from app kills or navigation away.
  • Approval workflow: Newly registered farmers enter PendingApproval status. 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 TypeComponentFunctionError Handling
1Manual TriggerAddFarmerScreenAgent initiates registration from home screen--
2IFFarmerDraftService.hasDraft()Check SharedPreferences for unfinished draftReturns false on exception
3FunctionFarmerDraftService.loadDraft()Deserialize JSON from SharedPreferences, restore DateTime fieldsNull on parse error
4FunctionFarmerDraftService.getFormattedDraftAge()Show human-readable time since last saveNull on error
5SetFarmerFormProviderState manager holding all 6 steps of form data, validation state--
6Multi-Step FormStep1PersonalInfo through Step6ReviewSubmitSix form widgets in AddFarmerScreen stepperPer-step validation with _stepsWithValidationErrors set
7FunctionFarmerDraftService.saveDraft()Serialize form map to JSON in SharedPreferences with UUID draft IDReturns false on failure
8FunctionFarmerService.createFarmer()Build LocalFarmer.create(), assign UUID, save locally, trigger syncReturns Result.failure() with message
9DatabaseFarmerLocalService.saveFarmer()Insert into SQLite farmers table with SyncStatus.pendingDuplicate phone check, merge-or-reject
10TriggerSyncManager.requestSubmitSync()Debounced background sync requestNon-blocking, logs errors
11HTTP RequestSyncServicePOST /api/farmers with CreateFarmerRequest JSON bodyRetry with exponential backoff; FK errors trigger master data refresh
12ValidationFarmersController.CreateFarmerFluentValidation via IValidator<CreateFarmerRequest>Returns 400 with property-level error list
13DatabaseIFarmerService.CreateFarmerAsyncEF Core insert into PostgreSQL Farmers table409 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. SyncManager retries on next connectivity event.
  • Duplicate phone (409): Backend returns conflict error. SyncService marks farmer as SyncStatus.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() in FarmerService.

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 TypeComponentFunctionError Handling
1Manual TriggerAdmin UIAdmin navigates to pending approval screen--
2HTTP RequestFarmerService.getPendingApprovalFarmers()GET /api/farmers/pending-approval with paginationReturns null on error; shows empty state
3IFAdmin decisionUser taps Approve or Reject button--
4FunctionFarmerService.approveFarmer()Local-first: load farmer, set ApprovalStatus.approved, save, trigger syncReturns Result.failure() if farmer not found
5DatabaseFarmerLocalService.updateFarmer()Update SQLite row with new approval status and SyncStatus.pending--
6HTTP RequestPOST /api/farmers/{id}/approveBackend endpoint with [Permission(Permission.ApproveFarmers)]404 if farmer not found
7FunctionFarmerApprovalService.ApproveFarmerAsync()Sets ApprovalStatus, records ReviewedById and ReviewedAtReturns Result with error
8ValidationRejection reason checkrejectionReason.trim().isEmpty guardReturns Result.failure('Rejection reason is required')
9HTTP RequestPOST /api/farmers/{id}/rejectBackend endpoint with [Permission(Permission.RejectFarmers)] + RejectFarmerRequest body400 if reason is empty
10HTTP RequestGET /api/farmers/my-rejectedAgent retrieves their rejected farmers for correctionReturns 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() returns Result.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: SyncConflictService handles 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 TypeComponentFunctionError Handling
1Manual TriggerAdmin UIAdmin navigates to farmer details and selects reassign--
2HTTP RequestPATCH /api/farmers/{id}/reassignReassignFarmerRequest { NewAgentId, Reason }404 farmer not found; 400 agent not found
3FunctionFarmersController.ReassignFarmer()Delegates to IFarmerService.ReassignFarmerAsync()Logs and returns appropriate HTTP status
4FunctionFarmerAgentService.ReassignFarmerAsync()Updates ManagedById column, records reasonReturns Result<FarmerDto>
5DatabasePostgreSQLUPDATE Farmers SET ManagedById = @newAgentId WHERE Id = @id--
6FunctionSyncService (old agent)Detects farmer no longer in agent's scope during pullEmits FarmerRemovedFromQueueNotification
7FunctionSyncService (new agent)Pulls farmer into local SQLite on next syncStandard sync error handling

Data Transformations

  • Admin input (target agent ID + reason) --> ReassignFarmerRequest --> PATCH /api/farmers/{id}/reassign
  • PostgreSQL: Farmers.ManagedById updated from old agent to new agent
  • Old agent mobile: On next pull, PullScopeResolver excludes farmer from scope. SyncService detects the change and emits FarmerRemovedFromQueueNotification on farmerRemovedStream.
  • 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, SyncService detects 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 TypeComponentFunctionError Handling
1Manual TriggerAgent UIAgent navigates to crop section of farmer profile--
2IFRegistration vs standaloneDuring registration: crops bundled in CreateFarmerRequest.crops; standalone: separate API call--
3FormStep4LandPlots widgetCrop selection from MasterDataProvider dropdowns (category --> crop filter)Empty dropdowns if master data not loaded
4FunctionLocalFarmerCrop creationUUID assigned, linked to farmer via farmerId--
5DatabaseFarmerLocalService.saveFarmer(farmer, crops: localCrops)Batch insert farmer + crops in SQLite transactionRolls back on failure
6HTTP RequestPOST /api/farmers/{farmerId}/crop-offersCreateFarmerCropOfferRequest with crop ID, volume, pricing, priorities404 farmer not found; 400 validation
7FunctionFarmerCropOfferService.CreateFarmerCropOfferAsync()Creates offer with linked priority and issue configurationsReturns Result<FarmerCropOfferDto>
8HTTP RequestPOST /api/farmers/{farmerId}/crops/{cropId}/photoMultipart file upload for planting photo400 no file; 404 farmer/crop not found

Data Transformations

  • Registration path: Form fields --> CreateFarmerCropRequest list --> LocalFarmerCrop objects --> SQLite farmer_crops table --> synced as part of farmer payload
  • Standalone path: Form fields --> CreateFarmerCropOfferRequest --> POST /api/farmers/{farmerId}/crop-offers --> FarmerCropOffer entity with linked PriorityIds and CommonIssueIds --> PostgreSQL
  • Photo: Local file path --> MultipartFile --> POST /api/farmers/{farmerId}/crops/{cropId}/photo --> DigitalOcean Spaces --> URL stored in plantingPhotoUrl

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 TypeComponentFunctionError Handling
1Manual TriggerAgent UIAgent selects farmer and initiates visit--
2FunctionGPS captureDevice geolocation for Latitude / LongitudeFalls back to null if permission denied
3SetStartVisitRequestFarmerId, AgentId, StartedAt, GPS, Purpose (enum: VisitPurpose)--
4HTTP RequestPOST /api/farmer-visits or POST /api/farmer-visits/startDual-route endpoint accepting StartVisitRequest400 validation errors
5ValidationIValidator<StartVisitRequest>FluentValidation rules for required fieldsReturns property-level errors
6FunctionFarmerVisitService.StartVisitAsync()Creates FarmerVisit entity with Status: InProgressReturns Result<FarmerVisitDetailDto>
7DatabasePostgreSQL insertINSERT INTO FarmerVisits with visit metadata--
8HTTP RequestPUT /api/farmer-visits/{id}/end or POST /api/farmer-visits/{id}/endEndVisitRequest with EndedAt, GPS, notes, VisitStatus404 if visit not found; 400 validation
9FunctionFarmerVisitService.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 } --> PostgreSQL FarmerVisits row
  • End: Agent input --> EndVisitRequest { EndedAt, Latitude, Longitude, Notes, Status } --> PostgreSQL update on existing visit row
  • Query: FarmerVisitFilterDto with pagination, date ranges, purpose/status filters --> PagedResult<FarmerVisitListDto>
  • Detail view: FarmerVisitDetailDto includes IReadOnlyList<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: FluentValidation and 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 TypeComponentFunctionError Handling
1Manual TriggerCamera / image pickerImagePicker captures photo from camera or gallery--
2FunctionLocal file storagePhoto saved to device filesystem--
3IFConnectivityService.instance.isConnectedCheck network state--
4DatabaseSQLite chunked_uploads tablePersist ChunkedUploadState for resume capability--
5HTTP RequestPOST /api/multipartupload/initiateInitiateMultipartUploadRequest { fileName, contentType, totalFileSize, folder, chunkSizeBytes }Exception on non-200
6FunctionChunkedUploadService.uploadParts()Iterates pending parts, reads 512KB chunks via RandomAccessFile, uploads to presigned URLsPer-part retry (3 attempts) with exponential backoff
7HTTP RequestPUT to S3 presigned URLRaw bytes with Content-Type: application/octet-streamRetries with Duration(seconds: pow(2, retries))
8FunctionETag extractionParse etag header from S3 response, strip quotesException if no ETag
9HTTP RequestPOST /api/multipartupload/completeCompleteMultipartUploadRequest { uploadId, objectKey, completedParts[] }Exception on non-200
10FunctionURL updateFinal S3 URL written to farmer or crop record--
11Cron/ScheduledcleanupOldUploads()Delete completed/aborted records older than 7 days from SQLiteLogged but non-blocking

Data Transformations

  • Photo file --> 512KB chunks (via RandomAccessFile seek + 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: ChunkedUploadService detects via Connectivity().checkConnectivity(), sets status to paused, saves progress. Already-uploaded parts are preserved via isUploaded flag and ETag storage.
  • Presigned URLs expired: State marked as failed with 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 failed with 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 standard MultipartFile upload to POST /api/farmers/{id}/photo without 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 MultipartUploadController handles the S3 lifecycle

Appendix: Key File Reference

Mobile (Flutter)

FilePurpose
mobile/mon_jardin/lib/presentation/add_farmer/add_farmer_screen.dart6-step farmer registration screen with stepper, draft resume, and form provider
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step1_personal_info.dartStep 1: name, DOB, gender, national ID, phone
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step2_location.dartStep 2: province, territory, village, GPS coordinates
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step3_socio_economic.dartStep 3: income, credit, savings, cooperative membership
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step4_land_plots.dartStep 4: land area, soil type, ownership, crop associations
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step5_infrastructure.dartStep 5: water, electricity, irrigation, tools, storage, transport
mobile/mon_jardin/lib/presentation/add_farmer/widgets/step6_review_submit.dartStep 6: summary review and submit
mobile/mon_jardin/lib/presentation/farmer_details/farmer_details_screen.dartFarmer detail view with assessments, sync status, edit actions
mobile/mon_jardin/lib/data/services/farmer_service.dartFarmer CRUD operations: local-first create/update, approval, photo upload
mobile/mon_jardin/lib/data/services/farmer_draft_service.dartAuto-save/load/clear draft via SharedPreferences with UUID tracking
mobile/mon_jardin/lib/data/local/farmer_local_service.dartSQLite CRUD for LocalFarmer with phone dedup and sync status management
mobile/mon_jardin/lib/data/services/sync_service.dartFull sync engine: push pending farmers, pull scoped farmers, conflict resolution
mobile/mon_jardin/lib/data/services/sync_manager.dartDebounced sync orchestrator: requestSubmitSync() triggers deferred push
mobile/mon_jardin/lib/data/services/chunked_upload_service.dartS3 multipart upload: 512KB chunks, presigned URLs, retry/resume, SQLite state
mobile/mon_jardin/lib/data/services/connectivity_service.dartNetwork state monitoring for offline-first decisions

Backend (ASP.NET Core)

FilePurpose
backend/Almafrica.API/Controllers/FarmersController.csREST endpoints: CRUD, approval, assignment, bulk import/export, crop offers, photo upload
backend/Almafrica.API/Controllers/FarmerVisitsController.csREST endpoints: start visit, end visit, list/filter visits
backend/Almafrica.Application/Interfaces/IFarmerService.csService contract: create, read, update, delete, approve, reject, reassign, bulk, export
backend/Almafrica.Application/Interfaces/IFarmerApprovalService.csApproval contract: approve, reject, set active status
backend/Almafrica.Application/Interfaces/IFarmerAgentService.csAgent assignment contract: get by agent, reassign, lookup by auth user
backend/Almafrica.Application/Interfaces/IFarmerCropOfferService.csCrop offer CRUD contract
backend/Almafrica.Application/Interfaces/IFarmerVisitService.csVisit contract: start, end, list with filters
backend/Almafrica.Application/Interfaces/IFarmerValidationService.csValidation service contract
backend/Almafrica.Application/DTOs/FarmerDto.csFarmer DTO with full field set
backend/Almafrica.Application/DTOs/FarmerCropOfferDto.csCrop offer DTOs: create/update requests, response with priorities and issues
backend/Almafrica.Application/DTOs/FarmerVisitDto.csVisit DTOs: list/detail views, start/end requests, filter
backend/Almafrica.Application/DTOs/FarmerSummaryDto.csLightweight farmer summary for list views
backend/Almafrica.Infrastructure/Services/Farmer/FarmerApprovalService.csApproval service implementation
backend/Almafrica.Infrastructure/Services/Farmer/FarmerAgentService.csAgent assignment service implementation
backend/Almafrica.Infrastructure/Services/Farmer/FarmerCropOfferService.csCrop offer service implementation
backend/Almafrica.Infrastructure/Services/Farmer/FarmerValidationService.csFarmer validation service implementation
backend/Almafrica.Infrastructure/Services/Farmer/FarmerBulkService.csBulk import/export service implementation