Files
100kmph_web/Instructions.md
T
2026-09-28 23:15:47 +05:30

215 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.