# Business rules — datafy_app (Datafy Ai)

Evidence date: 2026-10-18. Source: `git@bitbucket.org:overdrive/datafy_app.git`, `main` at `dba98cc`.

No business-rules document exists in the application repository. Every rule below is **Implemented** in code or **Inferred** from that code. None of them is a signed business rule. Where the code is the only source, the row says **Inferred** if a human could reasonably have meant something else.

## Access

| ID | Rule | Evidence | Label |
|---|---|---|---|
| BR-001 | A person with a Supabase session who opens `/` is shown Brands. A person without one is shown Login | `nav.dart` initial route | **Implemented** |
| BR-002 | Named routes do not set `requireAuth`. The redirect to `/login` runs only when that flag is true, and no route passes true | `FFRoute` default and a search for `requireAuth: true` with no matches | **Implemented**. Whether deep links are supposed to be public is **Unknown / Requires Confirmation** |
| BR-003 | Both login buttons authenticate with email and password. The mobile-number button sends the phone field as the email | `login_widget.dart` `signInWithEmail` | **Implemented**. **Inferred** that some accounts use a phone string as the Supabase email |
| BR-004 | After auth, the client will not open Brands unless location permission is granted | same file, `getPermissionStatus(locationPermission)` | **Implemented** |
| BR-005 | After permission is granted, Brands opens only when the device location service is enabled. Otherwise the OS location settings open | `checkGpsStatus`, `openLocationSettings` | **Implemented** |
| BR-006 | Email sign-in stores `app_user.role`. If that value is null, the stored role becomes `Rep` | `valueOrDefault(..., 'Rep')` | **Implemented** |
| BR-007 | Mobile-number sign-in stores `app_user.role` with a null-check operator and no default | `.role!` | **Implemented** |
| BR-008 | The only role string that selects rep screens is exactly `Rep`. Every other value, including `Admin`, takes the admin branch | `userRole == 'Rep'` else admin, in brands and nav | **Implemented**. Case-insensitive matching is not what this code does. **Requires confirmation** that `Admin` is the only other intended value |
| BR-009 | `user.super_admin` and `app_user.manager` are mapped in generated rows. The login and nav code that was read does not branch on them | `user.dart`, `app_user` columns in the PowerSync schema | **Implemented** columns. Behaviour of those fields is **Unknown** |
| BR-010 | Profile sign-out calls Supabase `auth.signOut()` | `supabase_auth_manager.dart` | **Implemented** |

## Brand and customer

| ID | Rule | Evidence | Label |
|---|---|---|---|
| BR-011 | The brand list is `customer_user` rows whose `user_id` equals the current Supabase user id | `brands_widget.dart` | **Implemented** |
| BR-012 | An empty brand list shows the empty-state widget `NoItemsWidget` | same file | **Implemented** |
| BR-013 | Tapping a brand sets `selectedCustomer` from `customer_id` and `selectedBrand` from `name` | same file | **Implemented** |
| BR-014 | A Rep brand tap writes `user_activity` with `type` `loggedIn` and `customer_id` equal to the selected customer, then opens the rep dashboard | same file | **Implemented** |
| BR-015 | A non-Rep brand tap writes `user_activity` with `type` `loggedIn` and `customer_id` literal `1`, then opens the admin dashboard | same file, `'customer_id': 1` | **Implemented**. **Inferred** that this may be unintended, because the selected customer was already stored. **Requires confirmation** |
| BR-016 | If the rep name is missing, activity rows store `Not set` | `valueOrDefault(..., 'Not set')` | **Implemented** |
| BR-017 | If location cannot be read, the default coordinate passed in is latitude 0, longitude 0, and its string can be stored on the activity row | `getCurrentUserLocation(defaultLocation: LatLng(0.0, 0.0))` | **Implemented** |

## Navigation by role

| ID | Rule | Evidence | Label |
|---|---|---|---|
| BR-018 | Home and Notifications nav buttons render only when role is `Rep` | `nav_bar_widget.dart` | **Implemented** |
| BR-019 | The store icon opens `/storePageMain` for `Rep` and `/dashboardAdmin` otherwise | same file | **Implemented** |
| BR-020 | The profile icon opens `/profileNew` for `Rep` and `/profileNewAdmin` otherwise | same file | **Implemented** |
| BR-021 | The rep nav bar watches `app_user` through PowerSync (`SELECT * FROM app_user`) and maps the first row into user data. The admin nav does not | same file | **Implemented** |

## Tasks, promos, KaChing

| ID | Rule | Evidence | Label |
|---|---|---|---|
| BR-022 | Rep task completion writes `status`, `image_url`, `note`, and `archived = 1` on `task`, both when PowerSync reports connected and when it does not | `task_details_widget.dart` `powersyncWrite` | **Implemented**. **Inferred** that "done" means archived, because no comment says so |
| BR-023 | The task write is given `gps` and `user` parameters. The SQL text does not reference `:gps` or `:user` | same call | **Implemented**. Those values are not stored by that statement |
| BR-024 | When PowerSync is connected, the rep task button also inserts `user_activity` type `task` and a `product_images` row through Supabase | same handler, inside the connected branch | **Implemented** |
| BR-025 | English status chips on the rep task screen are "Fixed" and "Not Fixed". The value saved is the chip controller's value, not a separately coded enum | localisation comments and `_model.choiceChipsValue` | **Implemented** labels. The exact stored string is the chip text |
| BR-026 | Admin task SQL that was read is `UPDATE task SET status = :status WHERE id = :id` and does not set `archived` | `task_details_admin_widget.dart` | **Implemented** |
| BR-027 | Rep promo completion sets `archived = 1` on `promos_linked_users` | `task_details_promos_widget.dart` | **Implemented** |
| BR-028 | Rep in-store promo completion runs SQL against `store_promos` | `task_details_promos_instore_widget.dart` | **Implemented** SQL text. The generated table and the PowerSync schema use `store_promo`. **Requires confirmation** |
| BR-029 | Rep KaChing completion sets `archived = 1` on `kaching_linked_users` | `task_details_kaching_widget.dart` | **Implemented** |
| BR-030 | English chip label sampled for promo and KaChing completion is "Complete" | localisation comments on those widgets | **Implemented** |
| BR-031 | A task update without an image shows "Partial update." / "Task was updated, but no image was uploaded." A false update flag shows "Update failed!" / "Please try again." | `task_details_widget.dart` | **Implemented** |

Stock counts on `store_user_link` (`oos`, `negative_stock`, `soh_no_sales`, `no_sales_100_days`, `availability`, `fixed_count`, `not_fixed_count`) are columns in the local schema. This document does not turn those column names into calculation rules. How a row becomes "out of stock" is **Unknown** from this repository. It is likely computed outside the app. **Requires confirmation.**

## Surveys

| ID | Rule | Evidence | Label |
|---|---|---|---|
| BR-032 | A survey response is unique for the combination of survey id, question id, user id, and store code. A second save updates that row | `save_survey_response.dart` | **Implemented** |
| BR-033 | The save returns null when survey id, question id, user id, store code, or answer type is null, or when user id or store code is empty | same file | **Implemented** |
| BR-034 | Answer types named by the action are string, number, boolean, choice, multiChoice, and image | comment in the same file | **Documented** in code. **Inferred** that the form only produces those types |

## Images and sharing

| ID | Rule | Evidence | Label |
|---|---|---|---|
| BR-035 | Uploads go to the Supabase bucket named `uploads`, except profile images, which use `profile`. The helper returns a public URL | `storage.dart`, `bucketName:` sites | **Implemented** |
| BR-036 | Many screens fall back to a bundled FlutterFlow or Supabase placeholder image when a row has no image | hardcoded `https://` fallbacks | **Implemented** |
| BR-037 | WhatsApp share on web puts the message and image URL into a `wa.me` link. On Android and iOS it opens the system share sheet | `share_to_whats_app.dart` | **Implemented** |

## Platform and release

| ID | Rule | Evidence | Label |
|---|---|---|---|
| BR-038 | The Android release build type signs with the debug keystore, even though a release `signingConfigs` block reads `key.properties` | `android/app/build.gradle` | **Implemented**. **Requires confirmation** |
| BR-039 | The web page asks robots not to index it | `web/index.html` `noindex` | **Implemented** |
| BR-040 | The package must not be published to pub.dev (`publish_to: none`) | `pubspec.yaml` | **Implemented** |
| BR-041 | Afrikaans is a supported locale and the sampled Afrikaans values are empty strings, so the user sees the English source only when the lookup is empty | `internationalization.dart` | **Implemented**. **Inferred** that Afrikaans copy was not finished |

## What this file does not claim

- It does not claim Row Level Security, because no SQL policies are in the repository.
- It does not claim that `flutter_app`'s role normalisation (any non-admin string becomes Rep) applies here. This client does the opposite split: only the exact string `Rep` is a rep.
- It does not claim a store must be within a GPS radius. No distance check was found in `custom_functions.dart`. Location is collected and stored, not compared to a store coordinate in the files that were read.
- It does not invent a rule for Spar versus non-Spar screens. Spar routes exist. `r_f_g_categories_widget.dart` opens `CategoryPageSpar`. The selection rule is **Unknown / Requires Confirmation**.
