215 lines
12 KiB
Markdown
215 lines
12 KiB
Markdown
# 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 <SYNC_TOKEN>`. 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.
|