Skip to main content

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 VersionMismatch for admin review.
  • Campaign Runs: Publishing a campaign creates a CampaignRun that 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 TypeComponentFunctionError Handling
1TriggerCampaignsController.CreateReceives admin request to create campaignReturns 401 if unauthorized
2FunctionCampaignService.CreateAsyncCreates Campaign entity + initial draft QuestionnaireVersionReturns validation errors via Result<T>
3DatabasePostgreSQLPersists campaign and draft questionnaire versionTransaction rollback on failure
4FunctionCampaignsController.SaveDraftAdmin iteratively builds questionnaire schemaSchema stored as JSON blob
5FunctionQuestionnaireService.PublishAsyncMarks questionnaire version as PublishedMust be in Draft status
6DecisionStatus checkValidates campaign has a published questionnaireReturns 409 Conflict if missing
7FunctionCampaignService.PublishAsyncCreates CampaignRun, sets status to PublishedCampaign must be Draft or Closed
8DatabasePostgreSQLWrites campaign status + new CampaignRunAtomic 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 (returns ErrorCode.Conflict)
  • Closing a campaign closes the active CampaignRun and 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 TypeComponentFunctionError Handling
1TriggerCampaignDetailScreen._showFarmerSelectionAndStartSurveyShows farmer selection bottom sheet, navigates to survey formSheet dismissal cancels flow
2FunctionSurveyFormProvider.initFromSchemaInitializes form state: schema, responses map, repeat counts, GPS coordinatesFetches agentId from JWT asynchronously
3DecisionDraft checkRestores existing draft answers or generates fresh UUID_normalizeResponsesForSchema sanitizes loaded data
4FunctionQuestionWidgetFactory.buildWithContextDispatches to type-specific widget builder for each of 21 question typesFalls back to TextField for unknown types
5FunctionSurveyFormProvider.setAnswerStores answer, clears validation error, schedules auto-saveGPS answers decoded via GpsAnswerCodec
6DecisionisQuestionVisibleEvaluates QuestionCondition.evaluate(answer) for conditional logicAll conditions must pass (AND logic)
7FunctionvalidateCurrentSectionChecks required fields, runs QuestionValidation rules per questionAdds failed keys to _validationErrors set
8DatabaseSQLite survey_responsesAuto-saves draft every 2 seconds via debounced timerConflictAlgorithm.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 by questionKey (or questionKey_repeat_N for repeatable sections)
  • Output: LocalSurveyResponse { id, campaignId, questionnaireVersionId, responseData: {answers}, isDraft: true, syncStatus: 'draft' }

Question Types Reference

TypeWidgetInput MethodValidation
integerNumberFieldNumeric keyboardMin/max range
decimalNumberField with decimalNumeric + decimal keyboardMin/max, decimal precision, configurable unit
textTextFieldStandard keyboardMax length, regex
booleanSegmentedButtonYes/No tapRequired check
singleChoiceRadioListTile groupSingle tap selectionRequired check
multiChoiceCheckboxListTile groupMulti-tap selectionMin/max selections
singleListDropdown + master dataSearch + select from MasterDataProviderRequired check
multiListMulti-select + master dataSearch + multi-selectMin/max items
phoneTextField + phone keyboardDigits entrydigits_only, min_length, max_length, regex
dateDatePickerCalendar selectionRequired check
photoImagePickerCamera or galleryRequired check
signatureSignaturePadFinger drawingRequired check, exported as PNG
gpsLocation buttonLocationService.getCurrentPositionRequired check, auto-populates lat/lon
barcodeMobileScannerCamera QR/barcode scanRequired check
fileFilePickerFile browser selectionMax size 20MB
addressCascading dropdownsProvince > Territory > VillagerequireTerritory, requireVillage config
displayTextRead-only TextNo inputNo validation
matrixGrid layoutSub-question answersPer-cell validation
geoBoundary / geojsonGeo captureMap/coordinate inputSchema-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/repeatMax bounds

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 TypeComponentFunctionError Handling
1TriggerSurveyReviewScreen._handleSubmitInvoked by submit button tap; routes to create or update pathDisabled while isSubmitting is true
2FunctionSurveyFormProvider.validateAllSectionsValidates every visible question across all sections and repeatsPopulates _validationErrors set
3Function_local.isResponseSubmittedPersistent duplicate check (survives app restarts)Returns early with error message
4Function_ensureQuestionnaireIsCurrentChecks campaign status and questionnaire version freshnessSkipped if offline (offline-first)
5Function_buildCleanAnswersStrips answers for hidden (conditionally invisible) questionsNormalizes GPS answers via GpsAnswerCodec
6DatabaseSQLite survey_responsessavePendingResponse: isDraft=false, syncStatus='pending'Uses ConflictAlgorithm.replace
7FunctionSyncQueueService.enqueueEntityAdds to unified sync queue with entityType + operationQueue persists across app restarts
8FunctionCampaignRemoteDataSource.submitResponseImmediate sync attempt: POST /api/v1/responsesFalls back to queued sync on failure
9FunctionSurveyResponseService.SubmitAsyncBackend: validates campaign, version, schema; creates SurveyResponseFlags VersionMismatch if questionnaire changed

Data Transformations

  • Input: SurveyFormProvider._responses (Map<String, dynamic> of all answers)
  • Processing: _buildCleanAnswers strips hidden questions; toApiJson builds { clientResponseId, campaignId, questionnaireVersionId, responseData, gpsLocation, submittedAt, runId, farmerId, agentId }
  • Output: Backend SurveyResponse entity with QualityStatus (NeedsReview, VersionMismatch, Ok)

Error Handling

  • Duplicate prevention: isResponseSubmitted check 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 VersionMismatch for 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 TypeComponentFunctionError Handling
1TriggerFile answer capturedAgent takes photo, draws signature, or picks file during surveyN/A
2FunctionSurveyFormProvider.uploadFileAttempts immediate upload via CampaignRemoteDataSource.uploadFileOn failure, queues file locally
3DatabaseSQLite survey_file_queueStores { id, response_id, question_key, local_file_path, upload_status }Created with status pending
4TriggerBackground sync cycleSurveyFileSyncService.pushPendingFiles called during syncProcesses all pending files sequentially
5FunctionCampaignRemoteDataSource.uploadFilePOST /api/v1/mobile/files with multipart form dataReturns { url, fileName, size }
6FunctionMobileSurveyController.UploadFileBackend: validates file, uploads to DigitalOcean Spaces via IMediaUploadServiceMax size 20MB; rejects empty files
7Function_updateResponseFileAnswerReplaces local path with remote URL in the response's answer dataReads response, updates map, saves back
8DatabaseSQLite survey_responsesUpdated responseData with remote URLs replacing local pathsPreserves 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
  • SurveyFileSyncResult reports synced, failed, and detailed errors list

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 TypeComponentFunctionError Handling
1TriggerPull sync cycle / refreshCampaignPullSyncService.pullForScope or CampaignProvider.fetchActiveCampaignsWarehouse/unknown roles skip
2FunctionCampaignRemoteDataSource.getActiveCampaignsGET /api/v1/mobile/campaignsReturns empty list on error
3FunctionMobileCampaignService.GetActiveCampaignsAsyncBackend: returns published campaigns visible to the requesting agentPermission-checked via [Permission(Permission.ViewCampaigns)]
4DatabaseSQLite campaigns_cachecacheCampaigns: upsert rows, delete campaigns no longer active remotelyUses ConflictAlgorithm.ignore + targeted update to preserve schema columns
5FunctiongetCampaignIdsMissingSchemaFinds campaign IDs where questionnaire_schema IS NULLReturns empty list if all cached
6FunctionCampaignRemoteDataSource.getCampaignDetailGET /api/v1/mobile/campaigns/:id returns full detail with questionnairePer-campaign error logged, does not abort batch
7FunctionMobileCampaignService.GetCampaignDetailAsyncBackend: returns MobileCampaignDetailDto with publishedQuestionnaire { id, schema, versionNumber }404 if campaign not found
8DatabaseSQLite campaigns_cachecacheQuestionnaireSchema: stores schema JSON, versionId, runId, versionNumber, publishedAtSchema JSON includes metadata fields prefixed with _
9FunctionQuestionnaireSchema.fromJsonParses schema to find questions with masterDataKey propertyUsed for master data pre-fetch
10FunctionCampaignRemoteDataSource.getMasterDataListGET /api/v1/mobile/master-data/:keyPer-key error logged, does not abort batch
11DatabaseSQLite survey_master_data_cachecacheMasterData: 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:

ColumnPurpose
idCampaign UUID
name, description, objectiveCampaign metadata
statusPublished / Draft / Closed
questionnaire_schemaFull JSON schema with sections, questions, conditions, validations
questionnaire_version_idUUID of the published questionnaire version
current_run_idActive 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._refreshCampaignsFromRemote detects changed campaigns (by currentRunNumber or updatedAt) 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

EndpointMethodControllerServicePurpose
/api/v1/campaignsPOSTCampaignsControllerCampaignService.CreateAsyncCreate campaign
/api/v1/campaignsGETCampaignsControllerCampaignService.GetAllAsyncList campaigns (admin, filtered)
/api/v1/campaigns/:idGETCampaignsControllerCampaignService.GetByIdAsyncCampaign detail (admin)
/api/v1/campaigns/:idPATCHCampaignsControllerCampaignService.UpdateAsyncUpdate campaign
/api/v1/campaigns/:id/publishPOSTCampaignsControllerCampaignService.PublishAsyncPublish campaign + create run
/api/v1/campaigns/:id/closePOSTCampaignsControllerCampaignService.CloseAsyncClose campaign + end run
/api/v1/campaigns/:id/questionnaireGET/PUTCampaignsControllerQuestionnaireServiceGet/save questionnaire draft
/api/v1/campaigns/:id/questionnaire/publishPOSTCampaignsControllerQuestionnaireService.PublishAsyncPublish questionnaire version
/api/v1/campaigns/:id/responsesGETCampaignsControllerSurveyResponseService.GetByCampaignAsyncList responses (admin)
/api/v1/campaigns/:id/exportGETCampaignsControllerSurveyExportService.ExportAsyncExport CSV/XLSX
/api/v1/campaigns/:id/statsGETCampaignsControllerCampaignService.GetStatsAsyncCampaign statistics
/api/v1/campaigns/:id/runsGETCampaignsControllerCampaignService.GetRunsAsyncCampaign run history
/api/v1/mobile/campaignsGETMobileSurveyControllerMobileCampaignService.GetActiveCampaignsAsyncActive campaigns (mobile)
/api/v1/mobile/campaigns/:idGETMobileSurveyControllerMobileCampaignService.GetCampaignDetailAsyncCampaign + questionnaire (mobile)
/api/v1/mobile/master-data/:keyGETMobileSurveyControllerSurveyMasterDataService.GetByKeyAsyncMaster data list (mobile)
/api/v1/mobile/filesPOSTMobileSurveyControllerIMediaUploadService.UploadDocumentAsyncFile upload (multipart)
/api/v1/mobile/campaigns/:id/my-responsesGETMobileSurveyControllerSurveyResponseService.GetByAgentAndCampaignAsyncAgent's own submissions
/api/v1/mobile/responses/:idPUTMobileSurveyControllerSurveyResponseService.UpdateResponseAsyncUpdate non-approved response
/api/v1/responsesPOSTSurveyResponsesControllerSurveyResponseService.SubmitAsyncSubmit single response
/api/v1/responses/batchPOSTSurveyResponsesControllerSurveyResponseService.BatchSubmitAsyncBatch submit responses
/api/v1/responses/:idGETSurveyResponsesControllerSurveyResponseService.GetByIdAsyncResponse detail (admin)
/api/v1/responses/:id/qualityPATCHSurveyResponsesControllerSurveyResponseService.UpdateQualityStatusAsyncSet quality status
/api/v1/responses/:id/correctionsPOST/GETSurveyResponsesControllerSurveyResponseService.CorrectFieldAsyncField-level corrections
/api/v1/master-dataGETSurveyMasterDataControllerSurveyMasterDataService.GetAllListsAsyncAll master data lists
/api/v1/master-data/:keyGET/PUTSurveyMasterDataControllerSurveyMasterDataServiceGet/update master data

Appendix: Mobile File Map

FilePurpose
mobile/mon_jardin/lib/presentation/campaigns/campaign_list_screen.dartCampaign list with filters (all, recent, with drafts, pending sync)
mobile/mon_jardin/lib/presentation/campaigns/campaign_detail_screen.dartCampaign detail with questionnaire info, farmer selection, start survey
mobile/mon_jardin/lib/presentation/campaigns/survey_form_screen.dartMulti-section survey form with progress tracking
mobile/mon_jardin/lib/presentation/campaigns/survey_review_screen.dartAnswer review screen with submit/edit flow
mobile/mon_jardin/lib/presentation/campaigns/draft_list_screen.dartDraft management with resume and delete
mobile/mon_jardin/lib/presentation/campaigns/my_submissions_screen.dartAgent's submitted responses with status badges and edit flow
mobile/mon_jardin/lib/presentation/campaigns/widgets/question_widget_factory.dart21 question type renderers with validation display
mobile/mon_jardin/lib/presentation/providers/campaign_provider.dartCampaign list/detail state, schema caching, master data loading
mobile/mon_jardin/lib/presentation/providers/survey_form_provider.dartForm state machine: answers, validation, conditions, draft save, submit
mobile/mon_jardin/lib/data/datasources/local/campaign_local_datasource.dartSQLite CRUD for campaigns, schemas, responses, drafts, master data, file queue
mobile/mon_jardin/lib/data/datasources/remote/campaign_remote_datasource.dartHTTP client for mobile campaign/survey API endpoints
mobile/mon_jardin/lib/data/models/campaign_models.dartCampaignDto, MobileCampaignDetailDto
mobile/mon_jardin/lib/data/models/survey_response_models.dartLocalSurveyResponse, SubmittedResponseDto, BatchSubmitRequest
mobile/mon_jardin/lib/data/services/sync/campaign_pull_sync_service.dartBackground pull: campaigns, schemas, master data
mobile/mon_jardin/lib/data/services/sync/survey_response_sync.dartPush sync for pending survey responses (batch + per-item)
mobile/mon_jardin/lib/data/services/sync/survey_file_sync.dartPush sync for pending file uploads
mobile/mon_jardin/lib/domain/enums/survey_question_type.dart21 question type enum with serialization
backend/Almafrica.API/Controllers/CampaignsController.csAdmin campaign CRUD + questionnaire + responses + export
backend/Almafrica.API/Controllers/MobileSurveyController.csMobile: campaigns, detail, files, master data, responses
backend/Almafrica.API/Controllers/SurveyResponsesController.csResponse submit, batch, quality review, corrections
backend/Almafrica.API/Controllers/SurveyMasterDataController.csMaster data CRUD
backend/Almafrica.Infrastructure/Services/Campaign/CampaignService.csCampaign lifecycle: create, publish (creates CampaignRun), close
backend/Almafrica.Infrastructure/Services/Campaign/SurveyResponseService.csResponse submission, validation, quality status, version mismatch detection
backend/Almafrica.Infrastructure/Services/Campaign/SurveyMasterDataService.csMaster data key/value management
backend/Almafrica.Infrastructure/Services/Campaign/SurveyExportService.csCSV/XLSX export
backend/Almafrica.Infrastructure/Services/Campaign/MobileCampaignService.csMobile-optimized campaign detail with published questionnaire