# Business rules — flutter_webportal (Datafy Web)

Evidence date: 2026-10-14. Source: `main` at `b98b9e2`.

Rules are copied from code, SQL migration names, `CHANGELOG.md`, or `docs/`. Each rule is marked **Documented** or **Inferred**. Nothing here is a new policy.

If a changelog line describes a migration, the rule is only live where that migration has been applied. Application status per environment is **Unknown / Requires Confirmation**.

## Tenancy and access

### BR-TENANT-01 — Selected customer is mandatory

**Documented** (`docs/data-isolation.md`). **Inferred** as the product rule the code is written to follow.

Every read of a customer-scoped table or RPC must pass `FFAppState().selectedCustomer` (or that screen’s own dropdown). If the id is `<= 0`, return empty data and do not query.

### BR-TENANT-02 — Client picker is the user’s links

**Documented** (changelog Security; `docs/data-isolation.md`).

The customer dropdown lists `customer_user` rows for the signed-in `user_id` (`loadLinkedCustomerOptions()`). It must not list every customer. Overview tabs that previously queried `customer_user` with no user filter were treated as a defect when RLS is off.

Exception **Documented** as accepted: a super-admin customer picker in `week_report_chart_widget` may load a full `customer_user` list for that picker only.

### BR-TENANT-03 — Login writes customer id 1

**Inferred** from `login_widget.dart` (assignment `FFAppState().selectedCustomer = 1` immediately after a successful `"user"` load).

This conflicts with BR-TENANT-01 unless customer `1` is always valid for every portal user. **Requires Confirmation**. Do not treat it as an approved business rule.

### BR-TENANT-04 — RLS depends on the database

**Documented**, and the documents disagree.

- `docs/index.md`: isolation is application code; no RLS.
- `docs/data-isolation.md` opening: legacy production has RLS disabled; greenfield applies `20260710120000_enable_rls_tenant_policies.sql` using `app_user`, then `"user"`, `super_admin`, `customer_user`, and `store_user_link`.
- Same file, KaChing section: “RLS is not used; isolation is the explicit parameter.”
- `docs/SCOPE.md`: app-layer checks remain required even where RLS exists. Legacy: app-layer only. Greenfield: RLS on.

**Requires Confirmation** which database the deployed `main` build uses, and whether those RLS migrations have been applied. The presence of the SQL files is **Implemented** in git only.

### BR-NAV — Sidebar visibility

**Inferred** from `navbar_widget.dart` (the conditions are code, not a written policy). **Documented** partially in `README.md` (SDC visible to Managers) and incorrectly in `docs/SCOPE.md` (SDC listed as SuperAdmin-only).

- Always: Dashboard, Tasks, Stores, Account, Logout.
- Admin **or** Manager **or** `superAdmin`: SDC Report.
- `superAdmin` only: Products, Special, KaChing, Surveys, Users, Customers, Notifications, Sjreport, Callsheet.

Sjreport and Callsheet menu actions open `ComingsoonWidget`. They do not open `/sjreport` or `/callsheet`.

Router auth is separate: hidden pages can still be opened by URL if the user has a session (`nav.dart`). **Requires Confirmation** whether that is acceptable.

### BR-ROLE-01 — Two user tables

**Documented** (`CHANGELOG.md` user-import instructions; `docs/FEATURE_REFERENCE.md`).

| Kind | Table | Email |
|---|---|---|
| Rep / Admin (app user) | `app_user` | Optional on import |
| Manager / Portal Admin / Web Admin | `"user"` | Required on import |

Portal login reads `"user"`, not `app_user` (`login_widget.dart`).

### BR-ROLE-02 — Manager store scope

**Documented** in Dart comments on `get_surveys_for_store.dart`, `get_surveys_for_user.dart`, `get_promos_for_store.dart`, `get_kachings_for_store.dart`, `get_fixed_tasks_last_7_days.dart`.

Admin and SuperAdmin see the customer’s stores (subject to the RPC). Manager sees stores linked to them via `store_user_link`.

Portal users whose role lives only on `"user"` must still resolve as Admin/Manager. Looking up `app_user` only and treating a null role as Manager hid all stores (**Documented** defect, migration `20260818100000_fix_get_surveys_for_store_user_role.sql`).

### BR-ROLE-03 — Deactivate is superadmin

**Documented** (`CHANGELOG.md`, `edit_user_widget.dart` guard `FFAppState().superAdmin == true`).

RPC `admin_deactivate_user` sets `"user".active = 0`, `app_user.archived = 1`, and bans the auth user (migration `20260422140000_user_deactivation.sql`, as described in the changelog).

### BR-ROLE-04 — Delete user is a Portal function

**Implemented** (`delete_user_with_linked_items.dart` calls slug `delete-user`). Linked-data behaviour is in the Lambda, not fully visible here. **Unknown** beyond the slug.

## Stores and links

### BR-LINK-01 — Active flag

**Documented** (migration `20260819060000_fix_bulk_link_active_column.sql`, changelog).

A `store_user_link` row counts as linked when `active = 1`. Inserts must set `active = 1`. Conflict with an inactive row reactivates it. The view `view_store_user_link` filters on `active = 1`.

### BR-LINK-02 — Do not fail the batch on an existing link

**Documented** (`20260806150000_bulk_link_skip_existing_store_user_link.sql`).

Re-linking the same `(user_id, store_code, customer_id)` skips that row. It is not a user-facing hard failure. Excel import treats `skipped_existing_count > 0` with zero new links as success, not “no stores matched” (`CHANGELOG.md`).

### BR-LINK-03 — Same store may belong to more than one customer

**Documented** (`20260817140000_bulk_link_no_scl_gate_multi_customer.sql`).

Uniqueness prefers `(user_id, store_code, customer_id)`, not `(user_id, store_code)` alone. Bulk link must not require the store to already exist in `store_customer_link`.

### BR-LINK-04 — Dashboard store set comes from `store_user_link`

**Documented** (migrations `20260821130000` through `20260821160000`).

`get_dashboard_overview`, `get_store_scores`, `get_active_users`, and `get_user_statistics` must not shrink the customer to the handful of rows in `store_customer_link`.

Earlier changelog text (`20250317120000_dashboard_overview_role_based_store_scoping.sql`) said the opposite when `store_customer_link` had rows. The August 2026 migrations supersede that gate. **Requires Confirmation** if any environment is still on the older function.

### BR-STORE-01 — Trim region, channel, and store code

**Documented** (`20260723130000_trim_store_region_channel_whitespace.sql`).

Before insert or update on `store` and `store_user_link`, `region`, `channel`, and `store_code` are `btrim`’d. Filters compare trimmed values, so a trailing space must not create a second region.

### BR-SCORE-01 — Which stores affect the score

**Documented** (`CHANGELOG.md`, migration `20250318200000_get_store_scores_exclude_bot_nam_regions.sql`, `README.md`).

Scores include active stores with `affects_scoring` on, and exclude regions BOT and NAM. The client also filters BOT/NAM if an old RPC response still contains them. Managers see user-linked stores only (`README.md` Mar 2025 note).

### BR-SCORE-02 — Sort by last done

**Documented** as a behaviour the UI expects (migration `20260914120000_get_store_scores_last_done_sort.sql`, test `test/sort_stores_by_last_done_test.dart`). The exact sort key is in that SQL. Not re-quoted here.

## KaChing and Specials

### BR-KC-01 — Link only inside the selected customer’s active stores

**Documented** (`docs/data-isolation.md` KaChing section; `20260915183000_kaching_customer_scoped_linking.sql`).

- Pass `p_customer_id` from the Campaigns dropdown.
- Do not infer the customer from global app state inside the link panel.
- Do not resolve store codes from the global `store` table.
- New rows: `status = 'Incomplete'`, `archived = 0`.
- Uniqueness: `(customer_id, kaching_id, lower(store_code))`.

### BR-KC-02 — Null end date is still active

**Documented** (`20260519143000_dashboard_kaching_null_end_still_active.sql`, changelog).

Counts treat `end_date` null as active (`end_date` is null OR `end_date >= current_date`). `expiring_soon_count` still requires a non-null end date within 7 days.

### BR-KC-03 — Import preflight before any write

**Documented** (`kaching_import_preflight.dart` named in changelog; `README.md`).

Reject bad dates and non-http image URLs before linking. HEAD the image URL. If the response content type is a format Flutter cannot decode, reject that row even when the file name looks like png/jpg. Timeouts and unknown types do not fail the row.

### BR-KC-04 — Master image vs submitted photo

**Documented** (changelog, `edit_ka_ching_widget.dart`).

Changing the campaign image updates `default_image_url` on existing `kaching_linked_users` rows for that `kaching_id`. It must not overwrite `image_url` / `photo_id` already submitted by a rep.

### BR-KC-05 — Completion freezes the completer

**Documented** (`20260917140000_kaching_actor_tracking.sql`, changelog).

The first transition to Complete stores `rep_full_name`, `gps`, and `fixed_at`. Later saves update last-edited (and last-viewed) activity. Activity rows use `user_activity.item_id = kaching_linked_users.id`.

### BR-KC-06 — Delete removes children first

**Documented** (`20260916140000_kaching_delete_cascade.sql`).

Deleting a campaign deletes gallery rows, assignments, and the master. Deleting only the master row is not success: foreign keys or RLS can delete zero rows while the UI reports success. That failure mode is why the cascade function exists.

### BR-KC-07 — Id sequences retry once

**Documented** (changelog; `insert_with_id_sequence_retry.dart`; migrations `20260917130000`, `20260917150000`, and the same class of fix for `customer_user` and `store_user_link`).

If `id`’s sequence is behind `MAX(id)`, insert hits the primary key before `ON CONFLICT` can skip a logical duplicate. The RPC resyncs and retries once. This is operational recovery, not a business identifier rule.

### BR-KC-08 — Admin completed export is not limited to the admin’s own stores

**Documented** (`20260917120000_kaching_completed_notes_export.sql`).

An admin export of completed KaChings includes Complete rows for the selected client, not only stores on that admin’s `store_user_link`. Default From is one year back.

### BR-PROMO-01 — Numeric campaign search skips the channel filter

**Documented** (changelog). **Inferred** as applying to both KaChing and Specials because the changelog names `AllKachingCall` and `AllPromosCall`.

Typing a numeric id loads that master row and does not hide it for a non-`ALL` channel label.

Specials bulk link uses the same “skip existing / page the lookup” pattern as KaChing (**Documented** in the promos 409 changelog entry). A full promo rule list equal to BR-KC-* was not verified.

## Surveys

### BR-SURV-01 — Linked list is customer-scoped

**Documented** (changelog, Surveys Manage).

`LinkedSurveysCall` requires `customer_id`. It omits `store_code` unless a store is selected, and uses equality rather than `ilike`, so user-linked submissions with null `store_code` still appear.

### BR-SURV-02 — Answers, not the blank template

**Documented** (changelog).

Store and user rows open submitted answers. The form template is definition-only. If `get_survey_response_detail` is missing, the UI falls back to tables. Store code match is case-insensitive.

## Tasks and scoring reports

### BR-TASK-01 — Portal edit is administrative

**Documented** (`docs/SCOPE.md`).

The portal can update task status and proof. The primary in-store fix flow is the mobile app, which is out of scope here.

### BR-SDC-01 — Days cover and DROS display

**Documented** (changelog).

`DROS QTY` (`system_dros`) and `DAYS COVER` (`days_cover`) show at most two decimal places with trailing zeros trimmed. Days cover is `sjreport.days_cover` for sjreport rows and null for task rows (rendered as `-`).

### BR-SDC-02 — Who may open the report

**Inferred** from the navbar (Admin, Manager, or superadmin). This is wider than `docs/SCOPE.md`. **Requires Confirmation** if SDC data inside the page is further restricted by RPC.

## Imports and files

### BR-IMP-01 — Sjreport 409 is “already running or already imported”

**Documented** (changelog). The decision is made by the AWS Lambda, not this repository. The portal must surface the failure. The file may already be in Storage and `sjreport_uploads`.

### BR-IMP-02 — Queue cron cadence

**Documented** (`README.md`).

`process_store_queue_smart_timeout` should be scheduled at `30 seconds`, not `1 seconds`, or lock timeouts occur. The update statement in the README is an operational fix, not app code.

### BR-IMP-03 — openpyxl workbooks can fail the Excel parser

**Documented** (changelog). Not a business rule. Files whose worksheet relationship target is absolute (`/xl/...`) fail the bundled `excel` package. Excel, Google Sheets, and LibreOffice files are unaffected.

## Auth session side effects

### BR-AUTH-01 — Adding a user must keep the admin session

**Documented** (changelog).

`auth.signUp` for a new user must not become the portal session. The admin refresh session is restored, and the customer dropdown must not reload as the new user (which would shrink the client list to the one auto-linked customer).

### BR-AUTH-02 — New users are linked to the selected customer

**Documented** (changelog). Add-user flows insert `customer_user` for the selected customer.

### BR-AUTH-03 — Implicit auth flow

**Implemented** (`supabase.dart`: `AuthFlowType.implicit`). No written product reason was found. **Inferred**: session JWT is what PostgREST and Portal calls send.

## Notifications

### BR-NOTIF-01 — Live feed is `user_activity`

**Documented** (`docs/FEATURE_REFERENCE.md`).

`/notifications` is the product feed (filter, read/unread). The mock `notification_widget` is not.

Navbar visibility is superadmin-only (**Inferred** from `navbar_widget.dart`), which is narrower than “any signed-in user can open the route”. **Requires Confirmation**.

## Reporting session

### BR-WW-01 — Superset session lifetime

**Documented** (changelog).

Admins start Waterwheel from the dashboard. Reports open after EC2/Superset are ready. The session auto-stops after one running hour. The backup Switch off control is visible only to superadmins (`waterwheel_reports_control.dart`).

## Rules that must not be treated as confirmed

| Topic | Why |
|---|---|
| `selectedCustomer = 1` on every login | Code does it; docs forbid unscoped or wrong-tenant queries |
| RLS on or off in production | Docs conflict; SQL exists; live database not inspected |
| SDC SuperAdmin-only | Scope doc vs navbar |
| FTP still deploys `main` | Deploy docs vs `bitbucket-pipelines.yml` |
| Create-account is a supported journey | Widget exists; router does not expose it; scope doc calls it secondary |
