# Business rules — flutterflow_sync

Evidence date: 2026-10-15. Source: `git@bitbucket.org:overdrive/flutterflow_sync.git`, `main` at `8525b9f` (2025-09-04).

Rules below are taken from `sync_rules.yaml`, `server/src/config/syncConfig.js`, route handlers, and cron. The YAML file is not loaded by the server. The JS module is what a running process would use. At this commit the SQL predicates match.

The repository does not say why a row must be archived, incomplete, or unsubmitted. Those predicates are **Implemented** filters. The business reason is **Unknown / Requires Confirmation**.

`NewKnowledgeBase.md` states extra rules (last-write-wins, Joi, exponential backoff). Where the code disagrees, the code is the implemented behaviour and the note is **Documented** only. This file does not promote the note into a rule.

## Who may call what

### BR-001 — App API identity

**Implemented.** `/api/sync`, `/api/data`, and `/api/upload` are registered behind the auth middleware module. The function inside that module accepts a Supabase access token and sets `userId` from the Supabase user id.

**Discrepancy.** `server.js` passes the module object into `app.use`, not the function. See `technical-documentation.md`. The intended rule is still visible in `auth.js`.

There is no role check after authentication. Any valid Supabase user of the configured project is treated as allowed to sync, read their local rows, write any `tableName`, and upload.

### BR-002 — Local rows are scoped by that user id

**Implemented.** Reads add `WHERE user_id = ?` with the token user. Writes stamp that `user_id`. File list and delete do the same.

A user can still pass another table name. The handler does not keep an allow-list of tables.

### BR-003 — Dashboard operator is one shared account

**Documented** and **Implemented.** Username must equal `DASHBOARD_USERNAME` (default `admin`). The password is checked with `bcrypt.compare` against `DASHBOARD_PASSWORD_HASH` or else `DASHBOARD_PASSWORD` (default `admin123` in code, `env.example`, and Compose).

**Discrepancy.** bcrypt is built for hashes. The shipped default is not a hash, and no code hashes it at startup. README tells the operator to log in with that default. Whether login succeeds was not executed. Confidence that a plaintext compare does not succeed: high, from the bcryptjs contract. **Requires confirmation** if a deployment overrides `DASHBOARD_PASSWORD` with a real hash in that same variable.

### BR-004 — Dashboard JSON is not session-gated

**Implemented.** `login`, `logout`, `auth/check`, `stats`, `logs`, `sync-status`, `pending-changes`, `conflicts`, `users`, `trigger-sync/:userId`, and `health` are mounted at `/dashboard/api` with no call to `dashboardAuth`.

`dashboardAuth` only wraps the HTML catch-all `GET /dashboard/*`.

**Inferred.** Anyone who can reach the port can read logs and metadata and can start a sync for any user id string. This is not stated as a product rule. It is what the route table does. **Requires confirmation** if a reverse proxy was meant to close this and is not in the repo.

### BR-005 — Uploaded files on disk are not tied back to the user at read time

**Implemented.** `GET /uploads` is `express.static` with no auth. `GET /api/upload/serve/:filename` checks that the file exists and streams it. It does not look up `file_uploads.user_id`.

Delete does check `user_id`.

## What is pulled

### BR-006 — Rules source

**Implemented.** Runtime rules are the `buckets` array in `server/src/config/syncConfig.js`.

**Documented.** `sync_rules.yaml` repeats the same buckets. README says the rules live in the JS file. Nothing parses the YAML.

**Inferred.** The YAML shape (`bucket_definitions`, `parameters`, `request.user_id()`, `bucket.user_id`, `bucket.store_code`) matches PowerSync sync-rule syntax. The repo never names PowerSync. **Unknown / Requires Confirmation** whether the YAML was an export.

### BR-007 — Global bucket

**Implemented.** These queries have no user predicate:

- `SELECT * FROM survey`
- `SELECT * FROM survey_multiple_choice`
- `SELECT * FROM survey_questions`
- `SELECT * FROM category_images`

Each caller's copy is stored under that caller's `user_id` in MySQL. Users do not share one local cache row.

### BR-008 — User bucket

**Implemented** SQL:

- `customer_user` where `id = {{user_id}}`
- `app_user` where `id = {{user_id}}`

`{{user_id}}` is replaced with the token user's id, quoted as a SQL string.

### BR-009 — Stores linked to the user

**Implemented.** `store_user_link` is not `SELECT *`. The column list is fixed: `id`, `name`, `created_at`, `gps`, `region`, `channel`, `store_code`, `active_products`, `channel_image`, `oos`, `negative_stock`, `soh_no_sales`, `no_sales_100_days`, `total_soh`, `availability`, `fixed_count`, `not_fixed_count`, `baking_aids`, `beans`, `beverages`, `condiments_s`, `desserts`, `fruit`, `gravy`, `infant_foods`, `jam`, `meals`, `meat`, `salads_pickels`, `spices_season`, `tomato`, `tomato_paste`, `vegetables`, `week_start_date`, `user_id`, `store_id`, `promos`, `kaching`, `category_data`, `customer_id`.

Predicate: `user_id = {{user_id}}`. No `archived` filter on this table.

### BR-010 — Store-scoped rows

**Implemented** predicate `store_code = {{store_code}}` on:

| Table | Extra predicate |
|---|---|
| `kaching_gallery` | none |
| `survey_store_link` | `status = 'Incomplete'` AND `archived = 0` AND `submitted = FALSE` |
| `survey_responses` | none |
| `kaching_linked_users` | `archived = 0` |
| `store_promo` | `archived = 0` |
| `promo_gallery` | none |
| `promos_linked_users` | `archived = 0` |
| `product_images` | none |
| `product_store` | none |
| `task` | `archived = 0`, and a fixed column list (id, name, status, customer_id, updated_at, note, barcode, chain, store_name, category, brand, product, variant, size, image_url, week_start_date, store_soh, system_dros, last_sold, created_at, store_code, product_id, article, dc_soh, last_ordered, last_received, archived, task_name, category_image) |

`survey_store_link` is the only table limited to incomplete, unsubmitted work.

**Discrepancy.** `getUserContext` returns `store_codes` (array) and does not set `store_code`. The placeholder is replaced with `NULL`. The parameter query that would select store codes is never run. A user with several stores is not synced once per store.

**Inferred** from the YAML comments ("Sync all stores belonging to the user"): the author wanted every store linked to the user. The JS substitution does not do that.

### BR-011 — Pull is a full replace of the JSON body

**Implemented.** `INSERT ... ON DUPLICATE KEY UPDATE data = VALUES(data), sync_status = 'synced'`. Matching key is `id`. There is no comparison with `updated_at`, no version column, and no write to `sync_conflicts`.

If the local row was `pending`, a later pull sets it back to `synced` and overwrites `data` with the remote JSON. **Inferred:** a queued local edit can be overwritten by the pull that runs in the same `handleClientSyncRequest` before pending changes are pushed, because the bucket loop finishes before `processPendingChanges`. Order in `handleClientSyncRequest`: pull all buckets, then push pending. **Requires confirmation** that this order is intended. It is what the function does.

### BR-012 — No incremental window

**Implemented.** Queries do not reference `last_sync_timestamp`. That column is updated after the query.

Changelog "incremental sync" is **Documented** only.

## What is pushed

### BR-013 — Allowed write operations

**Implemented.** `operation` must be `INSERT`, `UPDATE`, or `DELETE`. Anything else is HTTP 400, message `Invalid operation. Must be INSERT, UPDATE, or DELETE`.

### BR-014 — Local table is updated before Supabase accepts the write

**Implemented.** The local JSON row is inserted or updated as `pending` in the same request that enqueues `pending_changes`. The HTTP response says `Data queued for sync`.

UPDATE and DELETE require `data.id`. The code does not reject a missing id before running SQL.

DELETE sets `deleted_at` and leaves the row. Reads do not filter `deleted_at IS NULL`, so a deleted row still returns.

### BR-015 — Push shape

**Implemented.**

- INSERT: `supabase.from(table_name).insert(parsedData)`
- UPDATE: `.update(parsedData).eq('id', parsedData.id)`
- DELETE: `.delete().eq('id', parsedData.id)`

The Supabase `{ error }` value is not checked. A logical failure that does not throw is still marked `completed`.

### BR-016 — Retry

**Documented** in the changelog: retry with exponential backoff, `MAX_RETRY_ATTEMPTS` default 3.

**Implemented.** The selector is `status = 'pending' AND retry_count < MAX_RETRY_ATTEMPTS`. On the first throw the row becomes `failed` and `retry_count` increments. It no longer matches `pending`, so the attempt limit is not a retry loop. No backoff delay is coded.

### BR-017 — Conflicts

**Documented:** multiple strategies and a manual resolution default (`resolution` enum default `manual` on the table).

**Implemented:** the table can be created and listed. No path inserts `local_data` / `remote_data` or sets `resolved_at`. Cleanup deletes rows whose `resolved_at` is older than 7 days. With no writer, that delete removes nothing.

Do not treat last-write-wins as a rule. `NewKnowledgeBase.md` names it. The pull path overwrites JSON (BR-011) without labelling the overwrite as a strategy.

## Files

### BR-018 — Accepted uploads

**Implemented.** MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `application/pdf`, `text/plain`, `application/msword`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.

Other types: multer error `File type <mime> is not allowed`.

Max file size 10MB. Max 10 files on the multi route.

### BR-019 — Image derivatives

**Implemented** when `getFileInfo` reports an image: resize inside 1200×800 without enlargement, JPEG quality 85, thumbnail 200×200 cover at quality 70. If sharp throws, the route keeps the original and still queues the file.

`uploadToSupabase` is not part of this path.

### BR-020 — File sync target

**Implemented.** Pending operation is an INSERT into Supabase table name `file_uploads`, body equal to the local metadata object (paths, URLs, mime, size). The bytes stay on the middleware disk.

**Unknown / Requires Confirmation:** whether a `file_uploads` table exists on the Supabase project this was written for.

## Scheduling and retention

### BR-021 — Who the timer syncs

**Implemented.** Every 5 minutes, users who already have a `sync_metadata` row updated in the last 24 hours.

`SYNC_INTERVAL` does not change the cron expression.

A user who has never synced is not added by the timer.

### BR-022 — Retention

**Implemented.** Daily at 02:00 server local time (node-cron with no timezone option):

| Table | Delete when |
|---|---|
| `pending_changes` | `created_at` older than 7 days, any status |
| `sync_metadata` | `updated_at` older than 30 days |
| `sync_conflicts` | `resolved_at` older than 7 days |
| `sync_logs` | `timestamp` older than 30 days |

Pending changes older than 7 days are deleted even if still `pending`. **Inferred:** an unsynced write can be dropped by age. The code does not warn before delete.

Metadata deletion also removes the user from BR-021 until something syncs them again.

### BR-023 — Log files

**Implemented.** Winston files `logs/error.log`, `logs/combined.log`, `logs/sync.log`, each max 10MB × 5 files. Level from `LOG_LEVEL` (default `info`). Console transport only when `NODE_ENV !== 'production'`.

The midnight cron does not rotate. `LOG_MAX_SIZE` and `LOG_MAX_FILES` are unused.

`sync.log` is an extra file transport at level `info`. It is not a separate logger category filter beyond that. The `logger.sync` helpers prefix the message with `[SYNC]`.

## Dashboard and platform limits

### BR-024 — Rate limit

**Implemented.** 100 requests per IP per 15 minutes on `/api/` only. Response message: `Too many requests from this IP, please try again later.`

`/dashboard/api` and `/health` are outside that limiter.

### BR-025 — Body size

**Implemented.** JSON and urlencoded bodies up to 10MB.

### BR-026 — Session lifetime

**Implemented.** Dashboard cookie max age 24 hours. `secure` when `NODE_ENV === 'production'`. `httpOnly`. `saveUninitialized: false`.

### BR-027 — Error text

**Implemented.** `errorHandler` returns `err.message` and the stack when `NODE_ENV !== 'production'`. In production the client receives `Internal server error` and no stack.

Many route `catch` blocks respond with `error.message` before the handler, in every environment.

### BR-028 — CORS

**Implemented.** Origins are the comma-separated `CORS_ORIGINS` list, or `http://localhost:3000` if unset. Credentials are allowed. Socket.IO allows GET and POST from those origins.

## Rules that are not in this repository

- No pricing, tax, or currency rule.
- No approval workflow for surveys, tasks, or promos beyond the pull filter in BR-010.
- No password policy, lockout, or second factor for the dashboard.
- No per-store permission beyond "the token user id is written into the query". Store membership is not re-checked on write.
- No data-retention rule for files on disk. Cleanup cron does not unlink uploads.
- Product ownership of the retail column names (stock, promos, surveys) is **Unknown / Requires Confirmation**.
