07 — Campaigns & Surveys
Domain Introduction
The Campaigns & Surveys domain is the backbone of Almafrica's field data collection system. It enables administrators to design structured questionnaires, distribute them to field agents via campaigns, and collect responses even in areas with no connectivity. The domain spans the full lifecycle: campaign creation and publication on the backend, questionnaire schema distribution to mobile devices, offline-capable survey filling with 21 question types, local draft persistence, and a robust sync pipeline that pushes responses and file attachments to the backend when connectivity returns.
Key Design Principles
- Offline-First: Agents in rural DRC often lack connectivity. Every step from campaign display to survey submission works without a network connection.
- Questionnaire Versioning: Each campaign has versioned questionnaires. Submissions against stale versions are accepted but flagged as
VersionMismatchfor admin review. - Campaign Runs: Publishing a campaign creates a
CampaignRunthat ties a specific questionnaire version to a collection period. Runs enable re-opening campaigns with updated questionnaires. - Sync Queue Architecture: Submissions are persisted locally first, then enqueued to a unified sync queue (
SyncQueueService). The sync engine processes them in batch when online.
Domain Summary Diagram
graph TB
subgraph "Admin (Web)"
CREATE_CAMP["Create Campaign"]
DESIGN_Q["Design Questionnaire"]
PUBLISH["Publish Campaign"]
REVIEW["Review Responses"]
EXPORT["Export Data"]
end
subgraph "Backend API (.NET)"
CAMP_CTRL["CampaignsController<br/>api/v1/campaigns"]
MOBILE_CTRL["MobileSurveyController<br/>api/v1/mobile"]
RESP_CTRL["SurveyResponsesController<br/>api/v1/responses"]
MASTER_CTRL["SurveyMasterDataController<br/>api/v1/master-data"]
CAMP_SVC["CampaignService"]
RESP_SVC["SurveyResponseService"]
MASTER_SVC["SurveyMasterDataService"]
MOBILE_SVC["MobileCampaignService"]
PG[("PostgreSQL<br/>Campaigns, Responses,<br/>QuestionnaireVersions")]
end
subgraph "Mobile App (Flutter)"
CAMP_LIST["CampaignListScreen"]
CAMP_DETAIL["CampaignDetailScreen"]
SURVEY_FORM["SurveyFormScreen"]
SURVEY_REVIEW["SurveyReviewScreen"]
DRAFTS["DraftListScreen"]
SUBMISSIONS["MySubmissionsScreen"]
CAMP_PROVIDER["CampaignProvider"]
FORM_PROVIDER["SurveyFormProvider"]
Q_FACTORY["QuestionWidgetFactory"]
SQLITE[("SQLite<br/>campaigns_cache<br/>survey_responses<br/>survey_file_queue<br/>survey_master_data")]
end
subgraph "Sync Engine"
PULL_SYNC["CampaignPullSyncService"]
RESP_SYNC["SurveyResponseSyncService"]
FILE_SYNC["SurveyFileSyncService"]
SYNC_QUEUE["SyncQueueService"]
end
CREATE_CAMP --> CAMP_CTRL
DESIGN_Q --> CAMP_CTRL
PUBLISH --> CAMP_CTRL
REVIEW --> RESP_CTRL
EXPORT --> CAMP_CTRL
CAMP_CTRL --> CAMP_SVC --> PG
MOBILE_CTRL --> MOBILE_SVC --> PG
RESP_CTRL --> RESP_SVC --> PG
MASTER_CTRL --> MASTER_SVC --> PG
PULL_SYNC -->|"GET /api/v1/mobile/campaigns"| MOBILE_CTRL
RESP_SYNC -->|"POST /api/v1/responses/batch"| RESP_CTRL
FILE_SYNC -->|"POST /api/v1/mobile/files"| MOBILE_CTRL
CAMP_LIST --> CAMP_PROVIDER
CAMP_DETAIL --> CAMP_PROVIDER
SURVEY_FORM --> FORM_PROVIDER
SURVEY_FORM --> Q_FACTORY
SURVEY_REVIEW --> FORM_PROVIDER
DRAFTS --> SQLITE
SUBMISSIONS --> SQLITE
CAMP_PROVIDER --> SQLITE
FORM_PROVIDER --> SQLITE
PULL_SYNC --> SQLITE
RESP_SYNC --> SQLITE
FILE_SYNC --> SQLITE
FORM_PROVIDER --> SYNC_QUEUE
Workflows
WF-CAMP-01: Campaign Creation & Distribution
Trigger: Admin creates a campaign via the web admin panel Frequency: On-demand (admin action) Offline Support: No (admin-only, web-based)
Workflow Diagram
graph TD
A(("Admin Action<br/>Create Campaign")) --> B["POST /api/v1/campaigns<br/>CampaignsController.Create"]
B --> C["CampaignService.CreateAsync<br/>Campaign.Create + QuestionnaireVersion.CreateDraft"]
C --> D[["PostgreSQL<br/>Insert Campaign + Draft Version"]]
D --> E["Admin designs questionnaire<br/>PUT /api/v1/campaigns/:id/questionnaire"]
E --> F["QuestionnaireService.SaveDraftAsync<br/>Save schema JSON with sections, questions, conditions"]
F --> G{{"Questionnaire ready?"}}
G -->|"Yes"| H["POST /api/v1/campaigns/:id/questionnaire/publish<br/>QuestionnaireService.PublishAsync"]
H --> I[["PostgreSQL<br/>QuestionnaireVersion.Status = Published"]]
I --> J["POST /api/v1/campaigns/:id/publish<br/>CampaignService.PublishAsync"]
J --> K{{"Has published questionnaire version?"}}
K -->|"No"| L>"Error: Must have published questionnaire"]
K -->|"Yes"| M["CampaignRun.Create<br/>RunNumber = max + 1, links QuestionnaireVersionId"]
M --> N[["PostgreSQL<br/>Campaign.Status = Published<br/>CampaignRun inserted"]]
N --> O>"Campaign published<br/>Mobile agents can now pull it"]
O --> P["Mobile pull sync<br/>GET /api/v1/mobile/campaigns<br/>(see WF-CAMP-05)"]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | CampaignsController.Create | Receives admin request to create campaign | Returns 401 if unauthorized |
| 2 | Function | CampaignService.CreateAsync | Creates Campaign entity + initial draft QuestionnaireVersion | Returns validation errors via Result<T> |
| 3 | Database | PostgreSQL | Persists campaign and draft questionnaire version | Transaction rollback on failure |
| 4 | Function | CampaignsController.SaveDraft | Admin iteratively builds questionnaire schema | Schema stored as JSON blob |
| 5 | Function | QuestionnaireService.PublishAsync | Marks questionnaire version as Published | Must be in Draft status |
| 6 | Decision | Status check | Validates campaign has a published questionnaire | Returns 409 Conflict if missing |
| 7 | Function | CampaignService.PublishAsync | Creates CampaignRun, sets status to Published | Campaign must be Draft or Closed |
| 8 | Database | PostgreSQL | Writes campaign status + new CampaignRun | Atomic save |
Data Transformations
- Input:
CreateCampaignRequest { Name, Description, Objective } - Processing: Creates campaign entity, attaches draft questionnaire, admin iterates on schema JSON, publishes version, then publishes campaign creating a
CampaignRun - Output:
CampaignDto { Id, Name, Status="Published", PublishedAt, QuestionnaireVersionCount }
Error Handling
- Campaign cannot be published without a published questionnaire version (returns
ErrorCode.ValidationError) - Campaign update blocked while status is
Published(returnsErrorCode.Conflict) - Closing a campaign closes the active
CampaignRunand stops accepting new submissions
Cross-References
- Triggers: WF-CAMP-05 (mobile agents pull published campaigns)
- Triggered by: Admin web panel actions
WF-CAMP-02: Survey Filling (Offline-Capable)
Trigger: Agent taps "Start new survey" on CampaignDetailScreen
Frequency: On-demand (per agent, per farmer interaction)
Offline Support: Yes (full offline capability)
Workflow Diagram
graph TD
A(("Agent taps<br/>Start New Survey")) --> B{{"Farmer selection sheet"}}
B -->|"Select farmer"| C["Navigate to SurveyFormScreen<br/>with farmerId, farmerName"]
B -->|"Continue without farmer"| C
B -->|"Dismiss (swipe/back)"| Z>"No action"]
C --> D["SurveyFormProvider.initFromSchema<br/>Load schema, campaign, versionId, runId"]
D --> E{{"Resuming draft?"}}
E -->|"Yes"| F["Restore draft.responseData into _responses map<br/>_normalizeResponsesForSchema"]
E -->|"No"| G["Generate new UUID via Uuid().v4<br/>Initialize empty _responses"]
F --> H
G --> H
H["Render current section<br/>SurveyFormScreen.build"]
H --> I["QuestionWidgetFactory.buildWithContext<br/>Renders question by SurveyQuestionType"]
I --> J{{"Question type?"}}
J -->|"integer/decimal"| K["NumberField with unit suffix<br/>keyboard: numeric, decimal validation"]
J -->|"text"| K1["TextField with maxLength"]
J -->|"boolean"| K2["Switch / SegmentedButton<br/>Yes/No toggle"]
J -->|"singleChoice"| K3["RadioListTile group<br/>from question.options"]
J -->|"multiChoice"| K4["CheckboxListTile group"]
J -->|"singleList/multiList"| K5["MasterData dropdown<br/>CampaignProvider.loadMasterData(key)"]
J -->|"phone"| K6["PhoneField with digits_only,<br/>min_length, max_length validation"]
J -->|"date"| K7["DatePicker with calendar"]
J -->|"photo"| K8["ImagePicker (camera/gallery)<br/>Save to local, queue upload"]
J -->|"signature"| K9["SignaturePad capture<br/>Export as PNG, save locally"]
J -->|"gps"| K10["LocationService.getCurrentPosition<br/>GpsAnswerCodec encode"]
J -->|"barcode"| K11["MobileScanner QR/barcode<br/>Return scanned value"]
J -->|"file"| K12["FilePicker, upload or queue"]
J -->|"address"| K13["Cascading Province > Territory > Village<br/>MasterDataProvider dropdowns"]
J -->|"displayText"| K14["Read-only info block"]
J -->|"matrix"| K15["Grid of sub-questions"]
J -->|"geoBoundary/geojson"| K16["Geo data capture"]
K --> L
K1 --> L
K2 --> L
K3 --> L
K4 --> L
K5 --> L
K6 --> L
K7 --> L
K8 --> L
K9 --> L
K10 --> L
K11 --> L
K12 --> L
K13 --> L
K14 --> L
K15 --> L
K16 --> L
L["SurveyFormProvider.setAnswer(key, value)<br/>_scheduleDraftSave (2s debounce)"]
L --> M{{"Conditional logic<br/>isQuestionVisible?"}}
M -->|"Condition met"| N["Show dependent question"]
M -->|"Condition not met"| O["Hide question, skip in validation"]
N --> P{{"Section navigation"}}
O --> P
P -->|"Next"| Q["validateCurrentSection<br/>Check required + validation rules"]
Q -->|"Valid"| R{{"Last section?"}}
Q -->|"Invalid"| S>"Show validation errors<br/>_validationErrors set"]
R -->|"No"| T["nextSection<br/>_currentSectionIndex++"]
T --> H
R -->|"Yes"| U["Navigate to SurveyReviewScreen<br/>(WF-CAMP-03 trigger)"]
P -->|"Previous"| V["previousSection<br/>_currentSectionIndex--"]
V --> H
P -->|"Save Draft"| W["SurveyFormProvider.saveDraft<br/>Persist to SQLite as draft"]
W --> X[["SQLite survey_responses<br/>is_draft=1, sync_status='draft'"]]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | CampaignDetailScreen._showFarmerSelectionAndStartSurvey | Shows farmer selection bottom sheet, navigates to survey form | Sheet dismissal cancels flow |
| 2 | Function | SurveyFormProvider.initFromSchema | Initializes form state: schema, responses map, repeat counts, GPS coordinates | Fetches agentId from JWT asynchronously |
| 3 | Decision | Draft check | Restores existing draft answers or generates fresh UUID | _normalizeResponsesForSchema sanitizes loaded data |
| 4 | Function | QuestionWidgetFactory.buildWithContext | Dispatches to type-specific widget builder for each of 21 question types | Falls back to TextField for unknown types |
| 5 | Function | SurveyFormProvider.setAnswer | Stores answer, clears validation error, schedules auto-save | GPS answers decoded via GpsAnswerCodec |
| 6 | Decision | isQuestionVisible | Evaluates QuestionCondition.evaluate(answer) for conditional logic | All conditions must pass (AND logic) |
| 7 | Function | validateCurrentSection | Checks required fields, runs QuestionValidation rules per question | Adds failed keys to _validationErrors set |
| 8 | Database | SQLite survey_responses | Auto-saves draft every 2 seconds via debounced timer | ConflictAlgorithm.replace for upsert |
Data Transformations
- Input:
QuestionnaireSchema { sections: [{ questions: [{ key, type, label, required, conditions, validations, config }] }] } - Processing: Each question rendered by type; answers stored in
Map<String, dynamic>keyed byquestionKey(orquestionKey_repeat_Nfor repeatable sections) - Output:
LocalSurveyResponse { id, campaignId, questionnaireVersionId, responseData: {answers}, isDraft: true, syncStatus: 'draft' }
Question Types Reference
| Type | Widget | Input Method | Validation |
|---|---|---|---|
integer | NumberField | Numeric keyboard | Min/max range |
decimal | NumberField with decimal | Numeric + decimal keyboard | Min/max, decimal precision, configurable unit |
text | TextField | Standard keyboard | Max length, regex |
boolean | SegmentedButton | Yes/No tap | Required check |
singleChoice | RadioListTile group | Single tap selection | Required check |
multiChoice | CheckboxListTile group | Multi-tap selection | Min/max selections |
singleList | Dropdown + master data | Search + select from MasterDataProvider | Required check |
multiList | Multi-select + master data | Search + multi-select | Min/max items |
phone | TextField + phone keyboard | Digits entry | digits_only, min_length, max_length, regex |
date | DatePicker | Calendar selection | Required check |
photo | ImagePicker | Camera or gallery | Required check |
signature | SignaturePad | Finger drawing | Required check, exported as PNG |
gps | Location button | LocationService.getCurrentPosition | Required check, auto-populates lat/lon |
barcode | MobileScanner | Camera QR/barcode scan | Required check |
file | FilePicker | File browser selection | Max size 20MB |
address | Cascading dropdowns | Province > Territory > Village | requireTerritory, requireVillage config |
displayText | Read-only Text | No input | No validation |
matrix | Grid layout | Sub-question answers | Per-cell validation |
geoBoundary / geojson | Geo capture | Map/coordinate input | Schema-defined |
Error Handling
- Auto-save timer cancelled on widget dispose to prevent orphaned timers
- Phone validation has schema-defined rules (digits_only, min/max_length) with fallback when no schema rules exist
- File upload failures queue the file locally for later sync (see WF-CAMP-04)
- Repeatable sections enforce
repeatMin/repeatMaxbounds
Cross-References
- Triggers: WF-CAMP-03 (submit response), WF-CAMP-04 (file upload queue)
- Triggered by: WF-CAMP-01 (campaign published and available)
WF-CAMP-03: Survey Response Submission
Trigger: Agent taps "Submit Response" on SurveyReviewScreen
Frequency: On-demand (after completing a survey)
Offline Support: Yes (offline-first with sync queue)
Workflow Diagram
graph TD
A(("Agent taps<br/>Submit Response")) --> B["SurveyReviewScreen._handleSubmit"]
B --> C{{"Editing existing submission?"}}
C -->|"Yes"| D["SurveyFormProvider.updateSubmittedResponse<br/>PUT /api/v1/mobile/responses/:id"]
C -->|"No"| E["SurveyFormProvider.submitResponse"]
E --> F["validateAllSections<br/>Check all visible questions across all sections"]
F --> G{{"All valid?"}}
G -->|"No"| H>"Show 'fix validation errors'<br/>_submissionError set"]
G -->|"Yes"| I["Duplicate check<br/>_local.isResponseSubmitted(responseId)"]
I --> J{{"Already submitted?"}}
J -->|"Yes"| K>"'This response has already been submitted'"]
J -->|"No"| L["_ensureQuestionnaireIsCurrent<br/>Check campaign not closed + version not stale"]
L --> M{{"Campaign closed?"}}
M -->|"Yes"| N["_showCampaignClosedDialog<br/>Offer: Save as Draft or Discard"]
N -->|"Save Draft"| O["saveDraft<br/>Keep answers in SQLite"]
N -->|"Discard"| P>"Navigate back to campaign list"]
O --> P
M -->|"No"| Q{{"Questionnaire version changed?"}}
Q -->|"Yes"| R>"'Questionnaire was updated. Refresh campaign.'"]
Q -->|"No / Offline"| S["Build LocalSurveyResponse<br/>_buildCleanAnswers (strip hidden questions)"]
S --> T[["SQLite survey_responses<br/>savePendingResponse<br/>isDraft=false, syncStatus='pending'"]]
T --> U["SyncQueueService.enqueueEntity<br/>entityType: surveyResponse, operation: create"]
U --> V[["SQLite sync_queue<br/>Queued for background sync"]]
V --> W["SyncService.notifyLocalDataChanged"]
W --> X{{"Online?"}}
X -->|"Yes"| Y["Best-effort immediate sync<br/>POST /api/v1/responses"]
Y --> Z{{"Sync success?"}}
Z -->|"Yes"| AA["_local.markResponseSynced<br/>_syncQueue.markCompleted"]
Z -->|"No - Campaign closed"| AB["_markCampaignClosedLocally<br/>_rollbackQueuedResponse"]
AB --> AC>"Campaign closed dialog"]
Z -->|"No - Network error"| AD["Response stays queued<br/>Background sync will retry"]
X -->|"No"| AD
AA --> AE>"Success: 'Response saved and queued for sync'<br/>Navigate to campaign list"]
AD --> AE
D --> DF["validateAllSections"]
DF --> DG{{"Valid?"}}
DG -->|"No"| DH>"Validation errors"]
DG -->|"Yes"| DI["Save pending + enqueue update<br/>SyncQueueService: operation=update"]
DI --> DJ["Best-effort: PUT /api/v1/mobile/responses/:id"]
DJ --> DK>"Success or queued for background sync"]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | SurveyReviewScreen._handleSubmit | Invoked by submit button tap; routes to create or update path | Disabled while isSubmitting is true |
| 2 | Function | SurveyFormProvider.validateAllSections | Validates every visible question across all sections and repeats | Populates _validationErrors set |
| 3 | Function | _local.isResponseSubmitted | Persistent duplicate check (survives app restarts) | Returns early with error message |
| 4 | Function | _ensureQuestionnaireIsCurrent | Checks campaign status and questionnaire version freshness | Skipped if offline (offline-first) |
| 5 | Function | _buildCleanAnswers | Strips answers for hidden (conditionally invisible) questions | Normalizes GPS answers via GpsAnswerCodec |
| 6 | Database | SQLite survey_responses | savePendingResponse: isDraft=false, syncStatus='pending' | Uses ConflictAlgorithm.replace |
| 7 | Function | SyncQueueService.enqueueEntity | Adds to unified sync queue with entityType + operation | Queue persists across app restarts |
| 8 | Function | CampaignRemoteDataSource.submitResponse | Immediate sync attempt: POST /api/v1/responses | Falls back to queued sync on failure |
| 9 | Function | SurveyResponseService.SubmitAsync | Backend: validates campaign, version, schema; creates SurveyResponse | Flags VersionMismatch if questionnaire changed |
Data Transformations
- Input:
SurveyFormProvider._responses(Map<String, dynamic> of all answers) - Processing:
_buildCleanAnswersstrips hidden questions;toApiJsonbuilds{ clientResponseId, campaignId, questionnaireVersionId, responseData, gpsLocation, submittedAt, runId, farmerId, agentId } - Output: Backend
SurveyResponseentity withQualityStatus(NeedsReview, VersionMismatch, Ok)
Error Handling
- Duplicate prevention:
isResponseSubmittedcheck against local database prevents double-submit - Campaign closed: If the remote reports campaign is closed, the agent is shown a dialog to save as draft or discard
- Version mismatch: Backend accepts the response but flags it as
VersionMismatchfor admin review - Network failure: Response remains in sync queue with
syncStatus='pending'; background sync retries later - Rollback: If campaign-closed error is detected during immediate sync, the queued response and sync queue item are both removed
Cross-References
- Triggers: WF-CAMP-04 (file uploads queued during survey filling)
- Triggered by: WF-CAMP-02 (survey filling complete)
- Related: WF-SYNC (background sync engine processes the queue)
WF-CAMP-04: Survey File Upload Sync
Trigger: File-type answer captured (photo, signature, file) with failed or deferred upload Frequency: Event-driven (background sync cycle) Offline Support: Yes (core purpose is offline file queuing)
Workflow Diagram
graph TD
A(("File answer captured<br/>photo / signature / file")) --> B["SurveyFormProvider.uploadFile<br/>Attempt immediate upload"]
B --> C{{"Upload success?"}}
C -->|"Yes"| D>"Return remote URL<br/>Store URL in answer map"]
C -->|"No / Offline"| E["Queue file for later<br/>_local.saveFileQueueItem"]
E --> F[["SQLite survey_file_queue<br/>id, response_id, question_key,<br/>local_file_path, upload_status='pending'"]]
F --> G>"Return local file path as fallback<br/>Store local path in answer map"]
H(("Sync cycle triggered<br/>(background / connectivity restored)")) --> I["SurveyFileSyncService.pushPendingFiles"]
I --> J[["SQLite survey_file_queue<br/>Query WHERE upload_status='pending'<br/>ORDER BY created_at ASC"]]
J --> K{{"Pending files exist?"}}
K -->|"No"| L>"No-op, return SurveyFileSyncResult(0, 0)"]
K -->|"Yes"| M["For each pending file"]
M --> N["CampaignRemoteDataSource.uploadFile<br/>POST /api/v1/mobile/files<br/>multipart/form-data, folder='survey-files'"]
N --> O{{"Upload success?"}}
O -->|"Yes"| P["_local.markFileUploaded(fileId, remoteUrl)"]
P --> Q["_updateResponseFileAnswer<br/>Replace local path with remote URL<br/>in response's answer map"]
Q --> R[["SQLite survey_responses<br/>Updated responseData[questionKey] = remoteUrl"]]
O -->|"No"| S["Log error, increment failed count<br/>File stays in queue for next cycle"]
R --> T{{"More files?"}}
S --> T
T -->|"Yes"| M
T -->|"No"| U>"SurveyFileSyncResult<br/>{ synced: N, failed: M, errors: [...] }"]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | File answer captured | Agent takes photo, draws signature, or picks file during survey | N/A |
| 2 | Function | SurveyFormProvider.uploadFile | Attempts immediate upload via CampaignRemoteDataSource.uploadFile | On failure, queues file locally |
| 3 | Database | SQLite survey_file_queue | Stores { id, response_id, question_key, local_file_path, upload_status } | Created with status pending |
| 4 | Trigger | Background sync cycle | SurveyFileSyncService.pushPendingFiles called during sync | Processes all pending files sequentially |
| 5 | Function | CampaignRemoteDataSource.uploadFile | POST /api/v1/mobile/files with multipart form data | Returns { url, fileName, size } |
| 6 | Function | MobileSurveyController.UploadFile | Backend: validates file, uploads to DigitalOcean Spaces via IMediaUploadService | Max size 20MB; rejects empty files |
| 7 | Function | _updateResponseFileAnswer | Replaces local path with remote URL in the response's answer data | Reads response, updates map, saves back |
| 8 | Database | SQLite survey_responses | Updated responseData with remote URLs replacing local paths | Preserves all other response fields |
Data Transformations
- Input: Local file path (e.g.,
/data/user/0/.../photo_123.jpg) - Processing: Upload via multipart POST; backend stores in DigitalOcean Spaces under
survey-files/folder - Output: Remote URL (e.g.,
https://spaces.digitalocean.com/survey-files/photo_123.jpg) written back into response data
Error Handling
- Failed uploads stay in queue with
upload_status='pending'for the next sync cycle - Empty file paths are marked as uploaded with empty URL (no-op)
- Each file is processed independently; one failure does not block others
SurveyFileSyncResultreportssynced,failed, and detailederrorslist
Cross-References
- Triggers: None directly (results feed into WF-CAMP-03 response data)
- Triggered by: WF-CAMP-02 (file answers captured during survey filling)
- Related: WF-SYNC (file sync runs as part of the unified sync cycle)
WF-CAMP-05: Survey Master Data Sync
Trigger: Background pull sync or CampaignProvider.fetchActiveCampaigns
Frequency: Periodic (pull sync cycle) and on-demand (campaign list refresh)
Offline Support: Partial (requires connectivity to pull; cached data available offline)
Workflow Diagram
graph TD
A(("Pull sync triggered<br/>or Campaign list refresh")) --> B["CampaignPullSyncService.pullForScope"]
B --> C{{"Role check"}}
C -->|"Warehouse / Unknown"| D>"Skip campaign pull<br/>Return 0"]
C -->|"Agent / Admin"| E["CampaignRemoteDataSource.getActiveCampaigns<br/>GET /api/v1/mobile/campaigns"]
E --> F["MobileSurveyController.GetActiveCampaigns<br/>MobileCampaignService.GetActiveCampaignsAsync"]
F --> G[["PostgreSQL<br/>Campaigns WHERE Status=Published"]]
G --> H>"List of CampaignDto returned"]
H --> I["CampaignLocalDataSource.cacheCampaigns<br/>Insert/update local cache"]
I --> J[["SQLite campaigns_cache<br/>Upsert campaigns, remove stale entries"]]
J --> K["UnifiedSyncStatusService.updateSyncTime<br/>Record campaigns sync timestamp"]
K --> L{{"Missing schemas?"}}
L -->|"onlyMissing=true"| M["getCampaignIdsMissingSchema<br/>WHERE questionnaire_schema IS NULL"]
L -->|"Full refresh"| N["All campaign IDs"]
M --> O
N --> O
O["For each campaign needing schema"] --> P["CampaignRemoteDataSource.getCampaignDetail<br/>GET /api/v1/mobile/campaigns/:id"]
P --> Q["MobileSurveyController.GetCampaignDetail<br/>Returns: campaign + publishedQuestionnaire"]
Q --> R{{"Has published questionnaire?"}}
R -->|"No"| S["Skip this campaign"]
R -->|"Yes"| T["CampaignLocalDataSource.cacheQuestionnaireSchema<br/>Store schema JSON + versionId + runId"]
T --> U[["SQLite campaigns_cache<br/>questionnaire_schema, questionnaire_version_id,<br/>current_run_id columns updated"]]
U --> V["Parse QuestionnaireSchema.fromJson<br/>Extract masterDataKey from questions"]
V --> W{{"Questions reference master data?"}}
W -->|"No"| X["Done with this campaign"]
W -->|"Yes"| Y["For each masterDataKey"]
Y --> Z["CampaignRemoteDataSource.getMasterDataList<br/>GET /api/v1/mobile/master-data/:key"]
Z --> AA["MobileSurveyController.GetMasterData<br/>SurveyMasterDataService.GetByKeyAsync"]
AA --> AB["CampaignLocalDataSource.cacheMasterData<br/>Store items JSON"]
AB --> AC[["SQLite survey_master_data_cache<br/>list_key, name, items_json, cached_at"]]
AC --> AD{{"More master data keys?"}}
AD -->|"Yes"| Y
AD -->|"No"| X
X --> AE{{"More campaigns?"}}
AE -->|"Yes"| O
AE -->|"No"| AF["UnifiedSyncStatusService.updateSyncTime<br/>Record campaignSchemas + surveyMasterData timestamps"]
AF --> AG>"Pull complete<br/>Return campaign count"]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | Pull sync cycle / refresh | CampaignPullSyncService.pullForScope or CampaignProvider.fetchActiveCampaigns | Warehouse/unknown roles skip |
| 2 | Function | CampaignRemoteDataSource.getActiveCampaigns | GET /api/v1/mobile/campaigns | Returns empty list on error |
| 3 | Function | MobileCampaignService.GetActiveCampaignsAsync | Backend: returns published campaigns visible to the requesting agent | Permission-checked via [Permission(Permission.ViewCampaigns)] |
| 4 | Database | SQLite campaigns_cache | cacheCampaigns: upsert rows, delete campaigns no longer active remotely | Uses ConflictAlgorithm.ignore + targeted update to preserve schema columns |
| 5 | Function | getCampaignIdsMissingSchema | Finds campaign IDs where questionnaire_schema IS NULL | Returns empty list if all cached |
| 6 | Function | CampaignRemoteDataSource.getCampaignDetail | GET /api/v1/mobile/campaigns/:id returns full detail with questionnaire | Per-campaign error logged, does not abort batch |
| 7 | Function | MobileCampaignService.GetCampaignDetailAsync | Backend: returns MobileCampaignDetailDto with publishedQuestionnaire { id, schema, versionNumber } | 404 if campaign not found |
| 8 | Database | SQLite campaigns_cache | cacheQuestionnaireSchema: stores schema JSON, versionId, runId, versionNumber, publishedAt | Schema JSON includes metadata fields prefixed with _ |
| 9 | Function | QuestionnaireSchema.fromJson | Parses schema to find questions with masterDataKey property | Used for master data pre-fetch |
| 10 | Function | CampaignRemoteDataSource.getMasterDataList | GET /api/v1/mobile/master-data/:key | Per-key error logged, does not abort batch |
| 11 | Database | SQLite survey_master_data_cache | cacheMasterData: stores { list_key, name, items_json } | ConflictAlgorithm.replace for upsert |
Data Transformations
- Input: Backend campaign list + detail endpoints
- Processing: Campaigns cached with metadata; questionnaire schemas stored as JSON blobs; master data items (for dropdown questions) stored per key
- Output: Fully populated SQLite cache enabling offline survey rendering with all question options pre-loaded
Schema Cache Structure
The campaigns_cache table stores both campaign metadata and the questionnaire payload:
| Column | Purpose |
|---|---|
id | Campaign UUID |
name, description, objective | Campaign metadata |
status | Published / Draft / Closed |
questionnaire_schema | Full JSON schema with sections, questions, conditions, validations |
questionnaire_version_id | UUID of the published questionnaire version |
current_run_id | Active CampaignRun UUID |
Prefixed metadata embedded in the schema JSON:
_versionId,_versionNumber,_publishedAt,_currentRunId
Error Handling
- Individual campaign schema fetch failures are logged but do not abort the overall pull
- Individual master data key fetch failures are logged but do not abort the campaign processing
CampaignProvider._refreshCampaignsFromRemotedetects changed campaigns (bycurrentRunNumberorupdatedAt) and force-refreshes their schemas- Stale campaigns (no longer in the remote list) are removed from local cache during
cacheCampaigns
Cross-References
- Triggers: WF-CAMP-02 (cached schemas enable offline survey filling)
- Triggered by: WF-SYNC (background pull sync cycle), agent pull-to-refresh on
CampaignListScreen
Appendix: Backend API Surface
| Endpoint | Method | Controller | Service | Purpose |
|---|---|---|---|---|
/api/v1/campaigns | POST | CampaignsController | CampaignService.CreateAsync | Create campaign |
/api/v1/campaigns | GET | CampaignsController | CampaignService.GetAllAsync | List campaigns (admin, filtered) |
/api/v1/campaigns/:id | GET | CampaignsController | CampaignService.GetByIdAsync | Campaign detail (admin) |
/api/v1/campaigns/:id | PATCH | CampaignsController | CampaignService.UpdateAsync | Update campaign |
/api/v1/campaigns/:id/publish | POST | CampaignsController | CampaignService.PublishAsync | Publish campaign + create run |
/api/v1/campaigns/:id/close | POST | CampaignsController | CampaignService.CloseAsync | Close campaign + end run |
/api/v1/campaigns/:id/questionnaire | GET/PUT | CampaignsController | QuestionnaireService | Get/save questionnaire draft |
/api/v1/campaigns/:id/questionnaire/publish | POST | CampaignsController | QuestionnaireService.PublishAsync | Publish questionnaire version |
/api/v1/campaigns/:id/responses | GET | CampaignsController | SurveyResponseService.GetByCampaignAsync | List responses (admin) |
/api/v1/campaigns/:id/export | GET | CampaignsController | SurveyExportService.ExportAsync | Export CSV/XLSX |
/api/v1/campaigns/:id/stats | GET | CampaignsController | CampaignService.GetStatsAsync | Campaign statistics |
/api/v1/campaigns/:id/runs | GET | CampaignsController | CampaignService.GetRunsAsync | Campaign run history |
/api/v1/mobile/campaigns | GET | MobileSurveyController | MobileCampaignService.GetActiveCampaignsAsync | Active campaigns (mobile) |
/api/v1/mobile/campaigns/:id | GET | MobileSurveyController | MobileCampaignService.GetCampaignDetailAsync | Campaign + questionnaire (mobile) |
/api/v1/mobile/master-data/:key | GET | MobileSurveyController | SurveyMasterDataService.GetByKeyAsync | Master data list (mobile) |
/api/v1/mobile/files | POST | MobileSurveyController | IMediaUploadService.UploadDocumentAsync | File upload (multipart) |
/api/v1/mobile/campaigns/:id/my-responses | GET | MobileSurveyController | SurveyResponseService.GetByAgentAndCampaignAsync | Agent's own submissions |
/api/v1/mobile/responses/:id | PUT | MobileSurveyController | SurveyResponseService.UpdateResponseAsync | Update non-approved response |
/api/v1/responses | POST | SurveyResponsesController | SurveyResponseService.SubmitAsync | Submit single response |
/api/v1/responses/batch | POST | SurveyResponsesController | SurveyResponseService.BatchSubmitAsync | Batch submit responses |
/api/v1/responses/:id | GET | SurveyResponsesController | SurveyResponseService.GetByIdAsync | Response detail (admin) |
/api/v1/responses/:id/quality | PATCH | SurveyResponsesController | SurveyResponseService.UpdateQualityStatusAsync | Set quality status |
/api/v1/responses/:id/corrections | POST/GET | SurveyResponsesController | SurveyResponseService.CorrectFieldAsync | Field-level corrections |
/api/v1/master-data | GET | SurveyMasterDataController | SurveyMasterDataService.GetAllListsAsync | All master data lists |
/api/v1/master-data/:key | GET/PUT | SurveyMasterDataController | SurveyMasterDataService | Get/update master data |
Appendix: Mobile File Map
| File | Purpose |
|---|---|
mobile/mon_jardin/lib/presentation/campaigns/campaign_list_screen.dart | Campaign list with filters (all, recent, with drafts, pending sync) |
mobile/mon_jardin/lib/presentation/campaigns/campaign_detail_screen.dart | Campaign detail with questionnaire info, farmer selection, start survey |
mobile/mon_jardin/lib/presentation/campaigns/survey_form_screen.dart | Multi-section survey form with progress tracking |
mobile/mon_jardin/lib/presentation/campaigns/survey_review_screen.dart | Answer review screen with submit/edit flow |
mobile/mon_jardin/lib/presentation/campaigns/draft_list_screen.dart | Draft management with resume and delete |
mobile/mon_jardin/lib/presentation/campaigns/my_submissions_screen.dart | Agent's submitted responses with status badges and edit flow |
mobile/mon_jardin/lib/presentation/campaigns/widgets/question_widget_factory.dart | 21 question type renderers with validation display |
mobile/mon_jardin/lib/presentation/providers/campaign_provider.dart | Campaign list/detail state, schema caching, master data loading |
mobile/mon_jardin/lib/presentation/providers/survey_form_provider.dart | Form state machine: answers, validation, conditions, draft save, submit |
mobile/mon_jardin/lib/data/datasources/local/campaign_local_datasource.dart | SQLite CRUD for campaigns, schemas, responses, drafts, master data, file queue |
mobile/mon_jardin/lib/data/datasources/remote/campaign_remote_datasource.dart | HTTP client for mobile campaign/survey API endpoints |
mobile/mon_jardin/lib/data/models/campaign_models.dart | CampaignDto, MobileCampaignDetailDto |
mobile/mon_jardin/lib/data/models/survey_response_models.dart | LocalSurveyResponse, SubmittedResponseDto, BatchSubmitRequest |
mobile/mon_jardin/lib/data/services/sync/campaign_pull_sync_service.dart | Background pull: campaigns, schemas, master data |
mobile/mon_jardin/lib/data/services/sync/survey_response_sync.dart | Push sync for pending survey responses (batch + per-item) |
mobile/mon_jardin/lib/data/services/sync/survey_file_sync.dart | Push sync for pending file uploads |
mobile/mon_jardin/lib/domain/enums/survey_question_type.dart | 21 question type enum with serialization |
backend/Almafrica.API/Controllers/CampaignsController.cs | Admin campaign CRUD + questionnaire + responses + export |
backend/Almafrica.API/Controllers/MobileSurveyController.cs | Mobile: campaigns, detail, files, master data, responses |
backend/Almafrica.API/Controllers/SurveyResponsesController.cs | Response submit, batch, quality review, corrections |
backend/Almafrica.API/Controllers/SurveyMasterDataController.cs | Master data CRUD |
backend/Almafrica.Infrastructure/Services/Campaign/CampaignService.cs | Campaign lifecycle: create, publish (creates CampaignRun), close |
backend/Almafrica.Infrastructure/Services/Campaign/SurveyResponseService.cs | Response submission, validation, quality status, version mismatch detection |
backend/Almafrica.Infrastructure/Services/Campaign/SurveyMasterDataService.cs | Master data key/value management |
backend/Almafrica.Infrastructure/Services/Campaign/SurveyExportService.cs | CSV/XLSX export |
backend/Almafrica.Infrastructure/Services/Campaign/MobileCampaignService.cs | Mobile-optimized campaign detail with published questionnaire |