# Business rules — flutter_app (Datafy Ai)

Evidence date: 2026-10-15. Source: `develop` at `7d30344`.

Rules below are taken from code or from `README.md` / `POWERSYNC_OFFLINE_SYNC_RULES.md`. Nothing here is an agreed policy document. Each rule is marked **Implemented**, **Documented**, or **Inferred**.

Where the README and the pipeline disagree, both are recorded. Neither is treated as the approved rule.

## Access

### BR-001 — Two client roles

**Implemented.** `app_user.role` is normalised to `Admin` only when the trimmed value is `admin` in any letter case. Every other value becomes `Rep`. Login uses `'Rep'` when the role is missing.

Evidence: `main.dart`, `login_widget.dart`, `brands_widget.dart`.

**Requires confirmation:** whether the business has roles besides Admin and Rep. The client cannot express them.

### BR-002 — Admin screens reject non-admins

**Implemented.** Routes with `requireAdmin: true` redirect to `/` when `FFAppState().userRole` is not `Admin`.

Evidence: `lib/flutter_flow/nav/nav.dart`.

### BR-003 — Rep screens are not auth-gated by the router

**Implemented.** `requireAuth` defaults to false and no route sets it true. `/` shows Login when logged out and Brands when logged in. A direct open of a Rep path does not hit that redirect.

**Requires confirmation:** whether pages themselves assume a session.

### BR-004 — PowerSync is Rep-only and starts after a brand is chosen

**Implemented.** Brand tap initialises PowerSync only when the role is Rep. Admin brand tap does not. Login and cold start do not start it.

**Documented.** The dashboard may start it if a Rep opens the dashboard directly. `sync_rules.yaml` only downloads `app_user` rows with `LOWER(role) = 'rep'`.

### BR-005 — Location is required to finish sign-in

**Implemented.** After Supabase accepts the password, Brands is opened only when location permission is granted and `checkGpsStatus` is true. Otherwise the user sees "Please enable your location." or the app opens location settings.

This applies to both the phone path and the email path.

## Identity

### BR-006 — A phone number is an email address

**Implemented.** `phoneToEmail` strips non-digits and returns `{digits}@datafy.co.za`. A null phone throws `ArgumentError`. A 10-digit value is not given a country prefix (the comment in the function mentions a US number; the code does not add a prefix).

**Inferred.** Accounts for phone users are Supabase users whose email is that synthetic address. How those users are created is not in this repository.

## Store and brand

### BR-007 — A Rep works one selected brand at a time, but sync is not limited to that brand

**Documented and implemented in the sync file.** The UI asks the Rep to pick a customer before sync. `user_store_data` parameters are every `store_code` in `store_user_link` for `request.user_id()`, with no `customer_id` filter. The README says this is why sync can exceed a million operations, and that limiting it to the selected brand would need a sync-rule change.

**Requires confirmation:** whether syncing every linked store is the approved cost and privacy rule.

### BR-008 — Only active store links are synced

**Implemented** in `sync_rules.yaml`: `store_user_link` rows for the user with `active = 1`.

What sets `active` is **Unknown / Requires Confirmation**.

### BR-009 — Store-scoped data must not leak across stores

**Implemented** for task mapping: `supabaseRowsToTask` returns an empty list when `storeCode` is null or empty, and drops rows with a different `store_code`.

**Documented** for the same fail-closed pattern on promos, product images, a single store, galleries, and KaChing; for SQL `WHERE store_code = :storeCode` on store lists; for updates that include `store_code`; and for detail pages that pop on a store mismatch.

**Documented exception:** dashboard and notifications use `supabaseRowsToTasksAl` with no store filter so the Rep can see "all my tasks".

**Inferred.** This is a client and sync-rule control. The three SQL migrations do not add row-level security. Whether Supabase RLS also enforces it is **Unknown / Requires Confirmation**.

### BR-010 — Switching store clears store-specific state

**Documented.** `storeTasks`, `userStoreData`, and `singleStoreTotals` are cleared when the user opens a different store.

### BR-011 — Multi-brand Reps can change brand

**Documented.** The profile shows "Change brand" when `userBrandCount > 1`, using PowerSync `customer_user`. Admin does not get this card.

### BR-012 — Chain-specific screens

**Implemented.** SPAR, SUPERSPAR, and KWIKSPAR select SPAR task, category, and overview flows (`store_items_widget.dart`). `RFGCategoriesWidget` branches for SPAR and SUPERSPAR.

Other channel names come from data. DISCHEM is inserted by migration `20250223000000_channel_data_dischem.sql` when missing. The rest of `channel_data` is not in git.

## Tasks, specials, and KaChing

### BR-013 — Archived rows are not synced to the device

**Implemented** in `sync_rules.yaml`: `task`, `kaching_linked_users`, `store_promo`, and `promos_linked_users` require `archived = 0`.

### BR-014 — Fixed work is archived

**Documented.** When an Admin submits a task with a Fixed status, `archived` is set to 1.

**Implemented.** `isTaskStatusFixed` is true when the choice is non-empty and is neither the Not Fixed label nor the Incomplete label. It does not compare to the word Fixed, so locale or spacing on "Fixed" does not matter.

### BR-015 — A missing photo is not a placeholder

**Documented.** Completing a task, special, or KaChing without an image asks for confirmation. If the user continues, image fields stay empty and gallery inserts are skipped.

**Implemented.** `hasUploadedImageUrl` is false for null, empty, the FlutterFlow `R.png` asset URL, and `https://alb.datafyapp.co.za/storage/v1/object/public/uploads/R.png`. The constant comment says the placeholder is for UI only and must not be written.

### BR-016 — Submit outcomes

**Documented.** Success with an image, partial update without an image, and failure each have their own dialog. Failures are caught and shown. After success or partial success the app pushes the role-correct list (Admin lists for Admin, Rep lists for Rep) so PowerSync-only lists are not shown to an Admin.

### BR-017 — The task's store wins on writes

**Documented.** `product_images` inserts and the WhatsApp message widget use `taskData.storeCode` when present, otherwise the user's current store.

### BR-018 — Dates

**Implemented.** Display format constants are `dd-MM-yyyy HH:mm` and `dd-MM-yyyy`. `removeTimeFromStringCopy` reformats an `YYYY-MM-DD` substring to `dd-MM-yyyy`, or "No Date" when it cannot.

## Surveys

### BR-019 — Two ways a survey is assigned

**Implemented** in sync rules and the form list:

- Store-linked: `survey_store_link.store_code` equals the store. Synced only when status is `Incomplete`, `archived = 0`, and `submitted` is false.
- User-linked: `store_code` is null, `user_id` is the Rep, `archived = 0`, and submitted is false or status is `Incomplete`.

**Implemented.** The Rep form list shows a user-linked row when region, channel, and customer match the open store. A null `channel` on the link matches any store channel (`form_list_widget.dart`).

**Documented.** Creating a user-linked survey must set `region`, `channel`, and `customer_id` or it will not appear in the intended stores. That create path is not a screen in this app.

### BR-020 — Survey images sync later

**Documented.** Offline image answers use `survey_responses.photo_id` and the attachment queue, with task, promo, and KaChing photo ids.

### BR-021 — Answer shapes

**Inferred** from columns on `survey_responses` in the local schema: text, number, boolean, single choice, multi-choice, and image. The README lists the same types.

## Sync and device

### BR-022 — `request.user_id()` is only used in parameter queries

**Documented and implemented** in `sync_rules.yaml`. Data queries use `bucket.*`.

### BR-023 — Global survey definitions sync to every device

**Implemented.** Bucket `global` selects all rows from `survey`, `survey_multiple_choice`, `survey_questions`, and `category_images`, with no user filter.

**Requires confirmation:** whether every Rep should hold every customer's survey definitions and category images.

### BR-024 — Logout drops local sync state

**Documented.** `clearOnLogout` clears critical app state. `resetPowerSync` cancels subscriptions, clears the local database, and waits so the OS releases the file before the next login. `syncStatus` is reset so the UI does not keep a stale "downloading" state.

### BR-025 — Wi-Fi preference does not block an explicit sync

**Documented.** "Sync only on Wi-Fi" limits automatic sync. "Sync now" runs on mobile data when the user asks.

### BR-026 — Locked database errors are transient

**Implemented.** `main.dart` suppresses unhandled async errors whose text is `database is locked` or `SqliteException(5)` and whose stack mentions `sqlite_async` or a write transaction. The README says `lockTimeout` is 60 seconds. That timeout value was not re-read in the PowerSync options in this pass.

## Version and release

### BR-027 — `develop` publishes Android to Play production

**Implemented.** Pipeline step "Deploy to Play Store (Production Track)" runs `fastlane production`, which sets `track: "production"`.

**Conflict.** `README.md` says internal testing. **Requires confirmation** which track is the rule.

### BR-028 — Each develop build increments the patch

**Implemented.** `scripts/bump_version.sh` adds 1 to the patch (`1.0.79` → next build `1.0.80`). Version code is `100000 + BITBUCKET_BUILD_NUMBER` (local fallback is the Unix time). The same version string is written to the app pubspec and to `dependencies/ff_theme` and `dependencies/ff_commons` when those files exist. The commit message is `chore: bump version to {name}+{code} [skip ci]`.

### BR-029 — Minimum app version is written, and the client does not enforce it

**Implemented.** After upload, if `VERSION_NAME` and `SUPABASE_SERVICE_ROLE_KEY` are set, the pipeline PATCHes `version` where `id=1`, field `app_version`. A non-200/204 response is logged and does not fail the step description beyond that log.

**Implemented.** `ForceUpdateService` would compare the installed version to `app_version`, retry three times, and allow the app to run if the fetch fails or `app_version` is null. Web always skips the check. `main.dart` does not call it.

**Documented.** README: force update is off on purpose. iOS store id in the service is still `id0000000000`.

### BR-030 — iOS is a manual pipeline

**Implemented.** `develop` does not build iOS. Custom pipelines `build-ios` and `deploy-ios-testflight` need a self-hosted macOS runner. TestFlight upload sets `distribute_external: false` and `skip_submission: true`.

**Conflict.** README says `develop` runs Android and iOS.

## Data the client will not invent

### BR-031 — Placeholder images

**Implemented.** `FFAppConstants` comments: the FlutterFlow `R.png` URL is display-only and must not be written. The Supabase `R.png` URL is excluded from "has image" checks.

## Not established

- Who may create stores, products, tasks, promos, KaChing challenges, and surveys.
- Pricing, rewards payout, or what KaChing `reward` means in money.
- Retention, and who may see GPS and `rep_full_name` on task, promo, and KaChing rows.
- Supabase RLS and storage policies.
- Whether Play production is intentional.
- A business definition of OOS, negative stock, SOH with no sales, and "no sales 100 days". Those are columns on `store_user_link` and are shown as store health counts. The calculation is not in this app. **Inferred** that an upstream job fills them.
