# Agent prompt: 100kmph Next.js site and two-way sync API Do not change the Flutter app. The tablet already publishes bills with an unauthenticated `POST`. Full two-way sync is specified here so a later Flutter change can call it. ## Role Build a Next.js (App Router, TypeScript) app in `web/` for the 100kmph front-desk POS. Public pages are a placeholder home, a bill page at the existing SMS link, and a phone lookup for bike status. Route handlers are the API. Data lives in MariaDB. The shop tablet stays the offline writer; this server is the shared copy. Source of truth for columns is `lib/data/db/tables.dart` (Drift schema version 6, SQLite file `pos_v4.sqlite`). IDs are UUID v4 from `lib/core/ids.dart` (`Uuid().v4()`). Money is Sri Lankan rupees, formatted like `public_bill/bill.js`: `Rs ` plus `en-LK` with 2 fraction digits. Drift stores camelCase fields as snake_case columns. Use those snake_case names in MariaDB and in sync JSON. ## Deploy on Debian 12 The box already has Apache and MariaDB. Add Node.js 20 LTS. Do not replace Apache with a public Node port. - MariaDB database `hundredkmph`, one app user, no remote root. - `next build` standalone (or `next start`) bound to `127.0.0.1:3000`. - systemd unit `hundredkmph-web`. - Apache `ProxyPass` `/` to that port. WebSocket upgrade is not required. Keep TLS on Apache if a certificate already exists. - Env file root-only: `DATABASE_URL`, `SYNC_TOKEN` (long random secret). Never commit either. Document the unit file, the Apache snippet, and the MariaDB grants in `web/deploy/`. The app process must not listen on a public interface. ## Public site - `/` — empty placeholder. Shop name “100kmph” only. No catalog, no login, no admin. - `/b/[token]` — digital invoice. Same path the tablet already texts: `{publicBaseUrl}/b/{token}` from `lib/data/repositories/billing_repository.dart` (`billUrl`). Render the stored snapshot: shop name, address, phone, invoice number, date, customer name and phone, vehicle reg and model, line description / qty / rate / amount, total, payment method. Printable. Missing token: “Bill not found yet. If you just received the SMS, wait a moment and refresh.” - `/status` — one field, customer phone. Submit shows that customer’s bikes and the current job stage only. Phone match must copy `normalizePhone` in `lib/core/formatters.dart`: ```ts function normalizePhone(raw: string): string { let d = raw.replace(/\D/g, ""); if (d.length === 12 && d.startsWith("91")) d = d.slice(2); if (d.length === 11 && d.startsWith("0")) d = d.slice(1); return d; } ``` Compare the result to `customers.phone`. Status payload per bike: registration, make, model, stage label, time of the latest stage event. Stage values and labels: | value | label | | --- | --- | | `received` | Received | | `diagnostic` | Diagnostic | | `in_repair` | In Repair | | `quality_check` | Quality Check | | `ready` | Ready | | `billed` | Billed | Prefer the newest non-deleted job card whose stage is not `billed`. If every job is billed, or none exist, say the bike is not in the workshop. Unknown number: one line, “No bike found for this number.” Do not return engineer notes, complaint text, prices, staff, stock, or other customers’ rows. Rate-limit this route. ## Legacy bill publish (keep working now) Today the tablet does `POST {base}/api/bills/{token}` with the snapshot JSON and no auth (`publishInvoice` in `lib/data/repositories/billing_repository.dart`), then the page loads `GET` the same path. Implement both: - `POST /api/bills/[token]` — store the body JSON keyed by token. When the body is a bill snapshot, also upsert `invoices.public_token` and `invoices.snapshot_json`. Respond `201` with `{"ok":true}`. The tablet treats any HTTP 2xx as published. - `GET /api/bills/[token]` — public JSON snapshot for `/b/[token]`. `404` if missing. This route stays until the tablet uses `/api/sync`. Do not require `SYNC_TOKEN` on it. Cap the body (64 KiB is enough). Token charset: UUID. Snapshot shape the tablet posts today: ```json { "shopName": "100kmph", "address": "Service Centre", "phone": "", "number": "INV-0001", "token": "uuid", "createdAt": "2026-09-28T06:30:00.000Z", "kind": "job", "customerName": "", "customerPhone": "", "vehicleReg": "", "vehicleModel": "", "lines": [ {"description": "Labour", "qty": 1, "unitPrice": 500, "lineTotal": 500} ], "subtotal": 500, "total": 500, "paymentMethod": "cash" } ``` `kind` is `job` or `parts`. `paymentMethod` is `cash` or `card`. Render `total` and payment method. The current public page does not print subtotal or tax. ## Two-way sync API Auth: `Authorization: Bearer `. Reject anything else with 401. These routes are not linked from the public site. Tables (primary key `id` on every table): `staff_members`, `shop_settings`, `work_days`, `attendance_records`, `short_leaves`, `overtime_logs`, `customers`, `vehicles`, `job_cards`, `odo_readings`, `job_card_events`, `part_types`, `bike_models`, `bike_groups`, `bike_group_members`, `catalog_parts`, `catalog_part_models`, `part_variants`, `job_line_items`, `invoices`, `invoice_lines`, `payments`, `demand_logs`, `sms_outbox`, `publish_outbox`. Every row has `updated_at` and nullable `deleted_at`. `is_dirty` is tablet-only: accept it on push, store `0` on the server, do not use it for conflicts. Soft delete is `deleted_at` set. Never hard-delete synced rows. Conflict rule: last write wins by `updated_at`. Equal timestamps: incoming wins (idempotent upsert). If the server row is strictly newer, skip and return that id as rejected so the tablet pulls it. `POST /api/sync/push` ```json { "deviceId": "uuid", "tables": { "customers": [ { "id": "uuid", "phone": "771234567", "name": "Ada", "created_at": "2026-09-28T06:30:00.000Z", "updated_at": "2026-09-28T06:30:00.000Z", "deleted_at": null } ] } } ``` Response: ```json { "accepted": { "customers": ["uuid"] }, "rejected": { "customers": [] } } ``` Upsert by `id`. Ignore unknown table keys. Timestamps are ISO-8601 UTC. Apply one request in a transaction. `POST /api/sync/pull` ```json { "cursors": { "customers": { "updatedAt": "1970-01-01T00:00:00.000Z", "id": "" } } } ``` Return rows with `(updated_at, id)` strictly greater than the cursor, per table, ordered by `updated_at` then `id`, limit 500 per table, plus `nextCursors`. A missing cursor means the epoch cursor above. Include soft-deleted rows so the tablet can apply `deleted_at`. `pin_hash` may appear only on this pull, so a replacement tablet can log in. No other route, and no log line, may include `pin_hash`. v1 is one shop tablet. Do not allocate invoice numbers on the server. `shop_settings.next_invoice_number` is just another last-write-wins field. Two tablets incrementing it will clash. Put that in a short comment next to the column or the push handler. ```mermaid sequenceDiagram participant Tablet participant Api as NextApi participant Db as MariaDB Tablet->>Api: POST /api/sync/push dirty rows Api->>Db: upsert if incoming updated_at wins Api-->>Tablet: accepted and rejected ids Tablet->>Api: POST /api/sync/pull cursors Api->>Db: rows newer than cursor Api-->>Tablet: rows plus nextCursors ``` ## MariaDB One SQL migration under `web/db/`. Column types: `CHAR(36)` ids, `DATETIME(3)` timestamps, `TINYINT(1)` booleans, `DOUBLE` money and qty, `LONGTEXT` for `snapshot_json` and `payload_json`, `VARCHAR` for the remaining text. Indexes: `customers.phone`, `invoices.public_token` unique, `job_cards.customer_id`, `job_cards.vehicle_id`, and `(updated_at, id)` on each synced table. Do not enforce foreign keys. The tablet can push children before parents in one batch. Upsert each request in a transaction. Column list (snake_case). Every table also has `updated_at`, `deleted_at`, and `is_dirty` unless noted. `is_dirty` defaults to `0` on the server. - `staff_members`: `id`, `name`, `pin_hash`, `role`, `active` - `shop_settings`: `id`, `name`, `address`, `phone`, `gstin`, `tax_percent`, `sms_bill_template`, `sms_status_template`, `next_invoice_number`, `expected_open`, `expected_close`, `late_grace_minutes`, `public_base_url` - `work_days`: `id`, `day_key`, `started_at`, `ended_at`, `started_by_staff_id` - `attendance_records`: `id`, `work_day_id`, `staff_id`, `check_in_at`, `check_out_at`, `notes` - `short_leaves`: `id`, `work_day_id`, `staff_id`, `from_at`, `to_at`, `minutes`, `reason` - `overtime_logs`: `id`, `work_day_id`, `staff_id`, `from_at`, `to_at`, `minutes`, `reason` - `customers`: `id`, `phone`, `name`, `created_at` - `vehicles`: `id`, `customer_id`, `registration`, `vin`, `make`, `model_name`, `year`, `engine_capacity`, `bike_model_id`, `created_at` - `job_cards`: `id`, `vehicle_id`, `customer_id`, `brought_by_customer_id`, `complaint`, `stage`, `engineer_notes`, `created_at` - `odo_readings`: `id`, `read_at`, `vehicle_id`, `reading_km` - `job_card_events`: `id`, `job_card_id`, `stage`, `note`, `at` - `part_types`: `id`, `name` - `bike_models`: `id`, `name`, `brand` - `bike_groups`: `id`, `name`, `part_type_id`, `code`, `notes` - `bike_group_members`: `id`, `group_id`, `bike_model_id` - `catalog_parts`: `id`, `part_type_id`, `bike_model_id`, `group_id`, `is_universal` - `catalog_part_models`: `id`, `catalog_part_id`, `bike_model_id` - `part_variants`: `id`, `catalog_part_id`, `grade`, `brand`, `sku`, `cost`, `sale_price`, `qty_on_hand` - `job_line_items`: `id`, `job_card_id`, `kind`, `variant_id`, `description`, `qty`, `unit_price` - `invoices`: `id`, `number`, `public_token`, `job_card_id`, `customer_id`, `kind`, `subtotal`, `tax_percent`, `tax_amount`, `total`, `snapshot_json`, `publish_status`, `created_at` - `invoice_lines`: `id`, `invoice_id`, `description`, `qty`, `unit_price`, `line_total` - `payments`: `id`, `invoice_id`, `method`, `amount`, `at` - `demand_logs`: `id`, `part_type_id`, `bike_model_id`, `variant_id`, `qty`, `reason`, `created_at` - `sms_outbox`: `id`, `to_phone`, `body`, `status`, `error`, `invoice_id`, `job_card_id`, `created_at` - `publish_outbox`: `id`, `invoice_id`, `public_token`, `payload_json`, `status`, `error`, `created_at` Nullable in the tablet schema: `deleted_at` everywhere; `vehicles.year`, `vehicles.engine_capacity`, `vehicles.bike_model_id`; `job_cards.brought_by_customer_id`; `job_line_items.variant_id`; `invoices.job_card_id`, `invoices.customer_id`; `catalog_parts.bike_model_id`, `catalog_parts.group_id`; `bike_groups.part_type_id`; `demand_logs.variant_id`; `sms_outbox.invoice_id`, `sms_outbox.job_card_id`; `attendance_records.check_in_at`, `attendance_records.check_out_at`; `overtime_logs.from_at`, `overtime_logs.to_at`; `work_days.ended_at`. The tablet’s invoice `id` and `public_token` are two different UUIDs. Legacy `POST /api/bills/[token]` must not insert `invoices.id = token`, or a later sync of the real invoice will collide on the unique `public_token`. Store the posted JSON so `GET /api/bills/[token]` works immediately (a `bill_snapshots` row keyed by token, or `publish_outbox.payload_json`). If an `invoices` row with that `public_token` already exists, update its `snapshot_json` as well. `GET` reads `invoices.snapshot_json` first, then the legacy store. One token, one invoice row. ## Out of scope - Flutter sync client, PIN screens, kiosk, SMS sending. - Admin UI, landing-page marketing, online payment. - Replacing Apache or moving off MariaDB. ## Done when - `/` is a placeholder. - A snapshot `POST` then `/b/{token}` shows the bill. - `/status` with a normalized phone lists that customer’s bikes and stage, and a wrong number does not leak data. - Push then pull round-trips a customer and a soft-delete. An older push does not overwrite a newer row. - The app listens only on localhost. Apache is the public front.