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

12 KiB
Raw Permalink Blame History

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:

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:

{
  "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, 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.