12 KiB
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 buildstandalone (ornext start) bound to127.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}fromlib/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:
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 upsertinvoices.public_tokenandinvoices.snapshot_json. Respond201with{"ok":true}. The tablet treats any HTTP 2xx as published.GET /api/bills/[token]— public JSON snapshot for/b/[token].404if 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:
{
"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
{
"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:
{
"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
{
"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.
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,activeshop_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_urlwork_days:id,day_key,started_at,ended_at,started_by_staff_idattendance_records:id,work_day_id,staff_id,check_in_at,check_out_at,notesshort_leaves:id,work_day_id,staff_id,from_at,to_at,minutes,reasonovertime_logs:id,work_day_id,staff_id,from_at,to_at,minutes,reasoncustomers:id,phone,name,created_atvehicles:id,customer_id,registration,vin,make,model_name,year,engine_capacity,bike_model_id,created_atjob_cards:id,vehicle_id,customer_id,brought_by_customer_id,complaint,stage,engineer_notes,created_atodo_readings:id,read_at,vehicle_id,reading_kmjob_card_events:id,job_card_id,stage,note,atpart_types:id,namebike_models:id,name,brandbike_groups:id,name,part_type_id,code,notesbike_group_members:id,group_id,bike_model_idcatalog_parts:id,part_type_id,bike_model_id,group_id,is_universalcatalog_part_models:id,catalog_part_id,bike_model_idpart_variants:id,catalog_part_id,grade,brand,sku,cost,sale_price,qty_on_handjob_line_items:id,job_card_id,kind,variant_id,description,qty,unit_priceinvoices:id,number,public_token,job_card_id,customer_id,kind,subtotal,tax_percent,tax_amount,total,snapshot_json,publish_status,created_atinvoice_lines:id,invoice_id,description,qty,unit_price,line_totalpayments:id,invoice_id,method,amount,atdemand_logs:id,part_type_id,bike_model_id,variant_id,qty,reason,created_atsms_outbox:id,to_phone,body,status,error,invoice_id,job_card_id,created_atpublish_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
POSTthen/b/{token}shows the bill. /statuswith 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.