init
This commit is contained in:
+214
@@ -0,0 +1,214 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=mysql://hundredkmph:password@127.0.0.1:3306/hundredkmph
|
||||
SYNC_TOKEN=replace-with-a-long-random-secret
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BILL_BODY_LIMIT, readLimited } from "../../../../lib/body";
|
||||
import { BillStoreError, readBillPayload, storePublishedBill } from "../../../../lib/bills";
|
||||
import { isUuid } from "../../../../lib/ids";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteContext = { params: Promise<{ token: string }> };
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const { token } = await context.params;
|
||||
if (!isUuid(token)) return Response.json({ error: "not found" }, { status: 404 });
|
||||
try {
|
||||
const payload = await readBillPayload(token);
|
||||
if (!payload) return Response.json({ error: "not found" }, { status: 404 });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
} catch {
|
||||
console.error("bill read failed");
|
||||
return Response.json({ error: "not found" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const { token } = await context.params;
|
||||
if (!isUuid(token)) return Response.json({ error: "invalid token" }, { status: 400 });
|
||||
const raw = await readLimited(request, BILL_BODY_LIMIT);
|
||||
if (raw === null) return Response.json({ error: "body too large" }, { status: 413 });
|
||||
try {
|
||||
await storePublishedBill(token, raw);
|
||||
return Response.json({ ok: true }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof BillStoreError) {
|
||||
return Response.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error("bill publish failed");
|
||||
return Response.json({ error: "publish failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isAuthorized } from "../../../../lib/auth";
|
||||
import { pullChanges, SyncInputError } from "../../../../lib/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAuthorized(request.headers.get("authorization"))) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid json" }, { status: 400 });
|
||||
}
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return Response.json({ error: "invalid body" }, { status: 400 });
|
||||
}
|
||||
const cursors = (body as { cursors?: unknown }).cursors;
|
||||
try {
|
||||
const result = await pullChanges(cursors);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof SyncInputError) {
|
||||
return Response.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("sync pull failed");
|
||||
return Response.json({ error: "sync failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isAuthorized } from "../../../../lib/auth";
|
||||
import { pushChanges, SyncInputError } from "../../../../lib/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAuthorized(request.headers.get("authorization"))) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid json" }, { status: 400 });
|
||||
}
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return Response.json({ error: "invalid body" }, { status: 400 });
|
||||
}
|
||||
const tables = (body as { tables?: unknown }).tables ?? {};
|
||||
try {
|
||||
const result = await pushChanges(tables);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof SyncInputError) {
|
||||
return Response.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("sync push failed");
|
||||
return Response.json({ error: "sync failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { cache } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { readBillPayload } from "../../../lib/bills";
|
||||
import { BILL_NOT_FOUND, formatBillDate, parseBill, type BillView } from "../../../lib/bill-view";
|
||||
import { money } from "../../../lib/money";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const loadBill = cache(async (token: string): Promise<BillView | null> => {
|
||||
const raw = await readBillPayload(token);
|
||||
if (!raw) return null;
|
||||
return parseBill(raw);
|
||||
});
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { token } = await params;
|
||||
const bill = await loadBill(token);
|
||||
if (!bill) return { title: "100kmph invoice" };
|
||||
return { title: `${bill.number || "Invoice"} · 100kmph` };
|
||||
}
|
||||
|
||||
export default async function BillPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const bill = await loadBill(token);
|
||||
if (!bill) return <main id="bill">{BILL_NOT_FOUND}</main>;
|
||||
|
||||
const when = formatBillDate(bill.createdAt);
|
||||
return (
|
||||
<main id="bill">
|
||||
<h1>{bill.shopName || "100kmph"}</h1>
|
||||
<p className="muted">{bill.address}</p>
|
||||
<p className="muted">{bill.phone ? `Ph: ${bill.phone}` : ""}</p>
|
||||
<hr />
|
||||
<p>
|
||||
<strong>Invoice {bill.number}</strong>
|
||||
<br />
|
||||
{when}
|
||||
</p>
|
||||
<p>
|
||||
Customer: {bill.customerName || "-"}
|
||||
<br />
|
||||
Phone: {bill.customerPhone || "-"}
|
||||
{bill.vehicleReg ? (
|
||||
<>
|
||||
<br />
|
||||
Vehicle: {bill.vehicleReg} {bill.vehicleModel}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th className="num">Qty</th>
|
||||
<th className="num">Rate</th>
|
||||
<th className="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bill.lines.map((line, index) => (
|
||||
<tr key={index}>
|
||||
<td>{line.description}</td>
|
||||
<td className="num">{line.qty ?? ""}</td>
|
||||
<td className="num">{money(line.unitPrice)}</td>
|
||||
<td className="num">{money(line.lineTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="total">Total {money(bill.total)}</p>
|
||||
<p>Paid by: {bill.paymentMethod.toUpperCase()}</p>
|
||||
<p className="foot">Digital invoice from 100kmph. You can print this page if you need paper.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
:root {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.home {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
#bill {
|
||||
max-width: 720px;
|
||||
margin: 16px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#bill h1 {
|
||||
text-align: center;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
text-align: center;
|
||||
color: #444;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#bill table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#bill th,
|
||||
#bill td {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#bill th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.foot {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
max-width: 720px;
|
||||
margin: 16px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.status h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.status label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.status .error {
|
||||
color: #900;
|
||||
}
|
||||
|
||||
.status li {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#bill {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "100kmph",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function HomePage() {
|
||||
return <main className="home">100kmph</main>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { allowRequest, clientIp } from "../../lib/rate-limit";
|
||||
import { lookupBikes } from "../../lib/status";
|
||||
import type { StatusResult } from "../../lib/workshop";
|
||||
|
||||
export async function lookupStatus(_prev: StatusResult | null, formData: FormData): Promise<StatusResult> {
|
||||
const headerList = await headers();
|
||||
const ip = clientIp(headerList.get("x-forwarded-for"));
|
||||
if (!allowRequest(ip)) {
|
||||
return {
|
||||
message: null,
|
||||
error: "Too many lookups. Wait a minute and try again.",
|
||||
bikes: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await lookupBikes(String(formData.get("phone") ?? ""));
|
||||
} catch {
|
||||
console.error("status lookup failed");
|
||||
return { message: null, error: "Lookup failed. Try again.", bikes: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StatusForm } from "./status-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function StatusPage() {
|
||||
return (
|
||||
<main className="status">
|
||||
<h1>100kmph</h1>
|
||||
<StatusForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { lookupStatus } from "./actions";
|
||||
import { NOT_IN_WORKSHOP, type StatusResult } from "../../lib/workshop";
|
||||
|
||||
function formatWhen(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString("en-LK", { timeZone: "Asia/Colombo" });
|
||||
}
|
||||
|
||||
export function StatusForm() {
|
||||
const [state, action, pending] = useActionState<StatusResult | null, FormData>(lookupStatus, null);
|
||||
|
||||
return (
|
||||
<form action={action}>
|
||||
<label>
|
||||
Phone
|
||||
<input name="phone" inputMode="tel" autoComplete="tel" required />
|
||||
</label>
|
||||
<button type="submit" disabled={pending}>
|
||||
Check
|
||||
</button>
|
||||
{state?.error ? <p className="error">{state.error}</p> : null}
|
||||
{state?.message ? <p>{state.message}</p> : null}
|
||||
{state && state.bikes.length > 0 ? (
|
||||
<ul>
|
||||
{state.bikes.map((bike, index) => {
|
||||
const when = formatWhen(bike.stageAt);
|
||||
return (
|
||||
<li key={`${bike.registration}-${index}`}>
|
||||
<div>{bike.registration}</div>
|
||||
<div>
|
||||
{bike.make} {bike.model}
|
||||
</div>
|
||||
{bike.inWorkshop ? (
|
||||
<div>
|
||||
{bike.stageLabel}
|
||||
{when ? ` · ${when}` : ""}
|
||||
</div>
|
||||
) : (
|
||||
<div>{NOT_IN_WORKSHOP}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
-- 100kmph MariaDB schema (Drift tablet schema version 6, snake_case).
|
||||
-- Apply as an admin user. The app user has no DDL and no DELETE.
|
||||
-- Synced rows are soft-deleted via deleted_at. Do not hard-delete them.
|
||||
-- No foreign keys: the tablet may push children before parents.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_members (
|
||||
id CHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
pin_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(64) NOT NULL,
|
||||
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_staff_members_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shop_settings (
|
||||
id CHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
phone VARCHAR(64) NOT NULL DEFAULT '',
|
||||
gstin VARCHAR(64) NOT NULL DEFAULT '',
|
||||
tax_percent DOUBLE NOT NULL DEFAULT 18,
|
||||
sms_bill_template VARCHAR(4000) NOT NULL,
|
||||
sms_status_template VARCHAR(4000) NOT NULL,
|
||||
-- Last-write-wins. v1 does not allocate invoice numbers on the server. Two tablets incrementing this will clash.
|
||||
next_invoice_number INT NOT NULL DEFAULT 1 COMMENT 'Last-write-wins. v1 does not allocate invoice numbers on the server. Two tablets incrementing this will clash.',
|
||||
expected_open VARCHAR(16) NOT NULL DEFAULT '09:00',
|
||||
expected_close VARCHAR(16) NOT NULL DEFAULT '18:00',
|
||||
late_grace_minutes INT NOT NULL DEFAULT 120,
|
||||
public_base_url VARCHAR(512) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_shop_settings_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_days (
|
||||
id CHAR(36) NOT NULL,
|
||||
day_key VARCHAR(32) NOT NULL,
|
||||
started_at DATETIME(3) NOT NULL,
|
||||
ended_at DATETIME(3) NULL,
|
||||
started_by_staff_id CHAR(36) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_work_days_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendance_records (
|
||||
id CHAR(36) NOT NULL,
|
||||
work_day_id CHAR(36) NOT NULL,
|
||||
staff_id CHAR(36) NOT NULL,
|
||||
check_in_at DATETIME(3) NULL,
|
||||
check_out_at DATETIME(3) NULL,
|
||||
notes VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_attendance_records_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS short_leaves (
|
||||
id CHAR(36) NOT NULL,
|
||||
work_day_id CHAR(36) NOT NULL,
|
||||
staff_id CHAR(36) NOT NULL,
|
||||
from_at DATETIME(3) NOT NULL,
|
||||
to_at DATETIME(3) NOT NULL,
|
||||
minutes INT NOT NULL,
|
||||
reason VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_short_leaves_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS overtime_logs (
|
||||
id CHAR(36) NOT NULL,
|
||||
work_day_id CHAR(36) NOT NULL,
|
||||
staff_id CHAR(36) NOT NULL,
|
||||
from_at DATETIME(3) NULL,
|
||||
to_at DATETIME(3) NULL,
|
||||
minutes INT NOT NULL,
|
||||
reason VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_overtime_logs_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id CHAR(36) NOT NULL,
|
||||
phone VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_customers_phone (phone),
|
||||
KEY idx_customers_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vehicles (
|
||||
id CHAR(36) NOT NULL,
|
||||
customer_id CHAR(36) NOT NULL,
|
||||
registration VARCHAR(64) NOT NULL,
|
||||
vin VARCHAR(64) NOT NULL DEFAULT '',
|
||||
make VARCHAR(255) NOT NULL DEFAULT '',
|
||||
model_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`year` INT NULL,
|
||||
engine_capacity INT NULL,
|
||||
bike_model_id CHAR(36) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_vehicles_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_cards (
|
||||
id CHAR(36) NOT NULL,
|
||||
vehicle_id CHAR(36) NOT NULL,
|
||||
customer_id CHAR(36) NOT NULL,
|
||||
brought_by_customer_id CHAR(36) NULL,
|
||||
complaint VARCHAR(4000) NOT NULL,
|
||||
stage VARCHAR(64) NOT NULL,
|
||||
engineer_notes VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_job_cards_customer (customer_id),
|
||||
KEY idx_job_cards_vehicle (vehicle_id),
|
||||
KEY idx_job_cards_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS odo_readings (
|
||||
id CHAR(36) NOT NULL,
|
||||
read_at DATETIME(3) NOT NULL,
|
||||
vehicle_id CHAR(36) NOT NULL,
|
||||
reading_km INT NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_odo_readings_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_card_events (
|
||||
id CHAR(36) NOT NULL,
|
||||
job_card_id CHAR(36) NOT NULL,
|
||||
stage VARCHAR(64) NOT NULL,
|
||||
note VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
`at` DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_job_card_events_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS part_types (
|
||||
id CHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_part_types_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bike_models (
|
||||
id CHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
brand VARCHAR(255) NOT NULL DEFAULT '',
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_bike_models_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bike_groups (
|
||||
id CHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
part_type_id CHAR(36) NULL,
|
||||
code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
notes VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_bike_groups_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bike_group_members (
|
||||
id CHAR(36) NOT NULL,
|
||||
group_id CHAR(36) NOT NULL,
|
||||
bike_model_id CHAR(36) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_bike_group_members_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalog_parts (
|
||||
id CHAR(36) NOT NULL,
|
||||
part_type_id CHAR(36) NOT NULL,
|
||||
bike_model_id CHAR(36) NULL,
|
||||
group_id CHAR(36) NULL,
|
||||
is_universal TINYINT(1) NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_catalog_parts_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalog_part_models (
|
||||
id CHAR(36) NOT NULL,
|
||||
catalog_part_id CHAR(36) NOT NULL,
|
||||
bike_model_id CHAR(36) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_catalog_part_models_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS part_variants (
|
||||
id CHAR(36) NOT NULL,
|
||||
catalog_part_id CHAR(36) NOT NULL,
|
||||
grade VARCHAR(64) NOT NULL,
|
||||
brand VARCHAR(255) NOT NULL DEFAULT '',
|
||||
sku VARCHAR(128) NOT NULL DEFAULT '',
|
||||
cost DOUBLE NOT NULL DEFAULT 0,
|
||||
sale_price DOUBLE NOT NULL,
|
||||
qty_on_hand DOUBLE NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_part_variants_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_line_items (
|
||||
id CHAR(36) NOT NULL,
|
||||
job_card_id CHAR(36) NOT NULL,
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
variant_id CHAR(36) NULL,
|
||||
description VARCHAR(4000) NOT NULL,
|
||||
qty DOUBLE NOT NULL,
|
||||
unit_price DOUBLE NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_job_line_items_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id CHAR(36) NOT NULL,
|
||||
number VARCHAR(64) NOT NULL,
|
||||
public_token CHAR(36) NOT NULL,
|
||||
job_card_id CHAR(36) NULL,
|
||||
customer_id CHAR(36) NULL,
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
subtotal DOUBLE NOT NULL,
|
||||
tax_percent DOUBLE NOT NULL,
|
||||
tax_amount DOUBLE NOT NULL,
|
||||
total DOUBLE NOT NULL,
|
||||
snapshot_json LONGTEXT NOT NULL,
|
||||
publish_status VARCHAR(64) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_invoices_public_token (public_token),
|
||||
KEY idx_invoices_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||
id CHAR(36) NOT NULL,
|
||||
invoice_id CHAR(36) NOT NULL,
|
||||
description VARCHAR(4000) NOT NULL,
|
||||
qty DOUBLE NOT NULL,
|
||||
unit_price DOUBLE NOT NULL,
|
||||
line_total DOUBLE NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_invoice_lines_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id CHAR(36) NOT NULL,
|
||||
invoice_id CHAR(36) NOT NULL,
|
||||
method VARCHAR(64) NOT NULL,
|
||||
amount DOUBLE NOT NULL,
|
||||
`at` DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_payments_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS demand_logs (
|
||||
id CHAR(36) NOT NULL,
|
||||
part_type_id CHAR(36) NOT NULL,
|
||||
bike_model_id CHAR(36) NOT NULL,
|
||||
variant_id CHAR(36) NULL,
|
||||
qty DOUBLE NOT NULL,
|
||||
reason VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_demand_logs_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sms_outbox (
|
||||
id CHAR(36) NOT NULL,
|
||||
to_phone VARCHAR(64) NOT NULL,
|
||||
body VARCHAR(4000) NOT NULL,
|
||||
status VARCHAR(64) NOT NULL,
|
||||
error VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
invoice_id CHAR(36) NULL,
|
||||
job_card_id CHAR(36) NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_sms_outbox_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS publish_outbox (
|
||||
id CHAR(36) NOT NULL,
|
||||
invoice_id CHAR(36) NOT NULL,
|
||||
public_token CHAR(36) NOT NULL,
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
status VARCHAR(64) NOT NULL,
|
||||
error VARCHAR(4000) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
is_dirty TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_publish_outbox_sync (updated_at, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Legacy unauthenticated bill publish. Not a synced table.
|
||||
-- The posted token is the public token, never invoices.id.
|
||||
CREATE TABLE IF NOT EXISTS bill_snapshots (
|
||||
token CHAR(36) NOT NULL,
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (token)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,58 @@
|
||||
# Deploy 100kmph web on Debian 12
|
||||
|
||||
Apache and MariaDB are already on the box. Add Node.js 20 LTS. The Next process listens on `127.0.0.1:3000` only. Apache is the public front. Do not open port 3000.
|
||||
|
||||
## MariaDB
|
||||
|
||||
1. Edit the password in [mariadb-grants.sql](mariadb-grants.sql).
|
||||
2. Apply it as an admin: `mariadb < mariadb-grants.sql`
|
||||
3. Apply [../db/001_init.sql](../db/001_init.sql) as an admin, not as `hundredkmph`.
|
||||
4. Do not enable remote root.
|
||||
|
||||
The app user gets `SELECT`, `INSERT`, and `UPDATE` on `hundredkmph` from `localhost` only.
|
||||
|
||||
## App process
|
||||
|
||||
Build on the server (or copy a build from a machine with the same Node major version):
|
||||
|
||||
```sh
|
||||
cd /opt/hundredkmph/src/web
|
||||
npm ci
|
||||
npm run build
|
||||
mkdir -p /opt/hundredkmph/web
|
||||
rm -rf /opt/hundredkmph/web/*
|
||||
cp -a .next/standalone/. /opt/hundredkmph/web/
|
||||
mkdir -p /opt/hundredkmph/web/.next
|
||||
cp -a .next/static /opt/hundredkmph/web/.next/static
|
||||
```
|
||||
|
||||
Create a system user and a root-only env file:
|
||||
|
||||
```sh
|
||||
useradd --system --home /opt/hundredkmph/web --shell /usr/sbin/nologin hundredkmph
|
||||
install -d -m 0750 -o root -g root /etc/hundredkmph
|
||||
install -m 0600 -o root -g root /dev/null /etc/hundredkmph/web.env
|
||||
```
|
||||
|
||||
`/etc/hundredkmph/web.env` contains only:
|
||||
|
||||
```
|
||||
DATABASE_URL=mysql://hundredkmph:THE_PASSWORD@127.0.0.1:3306/hundredkmph
|
||||
SYNC_TOKEN=a-long-random-secret
|
||||
```
|
||||
|
||||
systemd reads that file as root before dropping to `hundredkmph`. `HOSTNAME=127.0.0.1` and `PORT=3000` are set in [hundredkmph-web.service](hundredkmph-web.service), so the process does not listen on a public interface.
|
||||
|
||||
```sh
|
||||
cp hundredkmph-web.service /etc/systemd/system/hundredkmph-web.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now hundredkmph-web
|
||||
```
|
||||
|
||||
`npm start` is the non-standalone equivalent: `next start -H 127.0.0.1 -p 3000`.
|
||||
|
||||
## Apache
|
||||
|
||||
Leave the existing TLS virtual host in place. Enable `proxy` and `proxy_http`, then include [apache-hundredkmph.conf](apache-hundredkmph.conf) inside that virtual host. WebSocket upgrade is not required.
|
||||
|
||||
`mod_proxy` appends the client address to `X-Forwarded-For`. The status lookup uses the last address in that header.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Include this inside the existing Apache virtual host.
|
||||
# Do not add a public Listen for Node. TLS stays on Apache.
|
||||
# Requires: a2enmod proxy proxy_http
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / http://127.0.0.1:3000/
|
||||
ProxyPassReverse / http://127.0.0.1:3000/
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=100kmph front desk
|
||||
After=network.target mariadb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hundredkmph
|
||||
Group=hundredkmph
|
||||
WorkingDirectory=/opt/hundredkmph/web
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HOSTNAME=127.0.0.1
|
||||
Environment=PORT=3000
|
||||
EnvironmentFile=/etc/hundredkmph/web.env
|
||||
ExecStart=/usr/bin/node /opt/hundredkmph/web/server.js
|
||||
Restart=on-failure
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Run as an admin, before the app starts.
|
||||
-- Do not create a remote root, and do not grant this user '@'%'.
|
||||
-- The app user cannot DELETE and cannot apply DDL.
|
||||
-- Soft-delete is an UPDATE of deleted_at.
|
||||
-- Replace the password, then: chmod 600 this file if you fill in a real password.
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS hundredkmph CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS 'hundredkmph'@'localhost' IDENTIFIED BY 'replace-with-a-long-random-password';
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON hundredkmph.* TO 'hundredkmph'@'localhost';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- Then apply the migration as an admin (the app user cannot):
|
||||
-- mariadb hundredkmph < /opt/hundredkmph/src/web/db/001_init.sql
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
|
||||
export function isAuthorized(header: string | null): boolean {
|
||||
const expected = process.env.SYNC_TOKEN;
|
||||
if (!expected || !header) return false;
|
||||
const match = /^Bearer (\S+)$/.exec(header);
|
||||
if (!match) return false;
|
||||
const presented = createHash("sha256").update(match[1]).digest();
|
||||
const actual = createHash("sha256").update(expected).digest();
|
||||
return timingSafeEqual(presented, actual);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export const BILL_NOT_FOUND =
|
||||
"Bill not found yet. If you just received the SMS, wait a moment and refresh.";
|
||||
|
||||
export type BillLine = {
|
||||
description: string;
|
||||
qty: number | string | null;
|
||||
unitPrice: number;
|
||||
lineTotal: number;
|
||||
};
|
||||
|
||||
export type BillView = {
|
||||
shopName: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
number: string;
|
||||
createdAt: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
vehicleReg: string;
|
||||
vehicleModel: string;
|
||||
lines: BillLine[];
|
||||
total: number;
|
||||
paymentMethod: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function parseBill(raw: string): BillView | null {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(data)) return null;
|
||||
const lines = Array.isArray(data.lines) ? data.lines.filter(isRecord) : [];
|
||||
return {
|
||||
shopName: asString(data.shopName),
|
||||
address: asString(data.address),
|
||||
phone: asString(data.phone),
|
||||
number: asString(data.number),
|
||||
createdAt: asString(data.createdAt),
|
||||
customerName: asString(data.customerName),
|
||||
customerPhone: asString(data.customerPhone),
|
||||
vehicleReg: asString(data.vehicleReg),
|
||||
vehicleModel: asString(data.vehicleModel),
|
||||
lines: lines.map((line) => ({
|
||||
description: asString(line.description),
|
||||
qty: typeof line.qty === "number" || typeof line.qty === "string" ? line.qty : null,
|
||||
unitPrice: asNumber(line.unitPrice),
|
||||
lineTotal: asNumber(line.lineTotal),
|
||||
})),
|
||||
total: asNumber(data.total),
|
||||
paymentMethod: asString(data.paymentMethod),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBillDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString("en-LK", { timeZone: "Asia/Colombo" });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { BILL_BODY_LIMIT } from "./body";
|
||||
import { withTransaction, getPool } from "./db";
|
||||
import { isUuid } from "./ids";
|
||||
|
||||
export class BillStoreError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function storePublishedBill(token: string, raw: string): Promise<void> {
|
||||
if (!isUuid(token)) throw new BillStoreError(400, "invalid token");
|
||||
if (Buffer.byteLength(raw, "utf8") > BILL_BODY_LIMIT) throw new BillStoreError(413, "body too large");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new BillStoreError(400, "invalid json");
|
||||
}
|
||||
const isSnapshot = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
await withTransaction(async (conn) => {
|
||||
await conn.query(
|
||||
`INSERT INTO bill_snapshots (token, payload_json, updated_at)
|
||||
VALUES (?, ?, UTC_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE payload_json = VALUES(payload_json), updated_at = UTC_TIMESTAMP(3)`,
|
||||
[token, raw],
|
||||
);
|
||||
if (isSnapshot) {
|
||||
await conn.query<ResultSetHeader>(
|
||||
`UPDATE invoices SET snapshot_json = ? WHERE public_token = ? AND deleted_at IS NULL`,
|
||||
[raw, token],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoice snapshot first, then the legacy store. */
|
||||
export async function readBillPayload(token: string): Promise<string | null> {
|
||||
if (!isUuid(token)) return null;
|
||||
const pool = getPool();
|
||||
const [invoices] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT snapshot_json FROM invoices WHERE public_token = ? AND deleted_at IS NULL LIMIT 1`,
|
||||
[token],
|
||||
);
|
||||
const snapshot = invoices[0]?.snapshot_json;
|
||||
if (typeof snapshot === "string" && snapshot.length > 0) return snapshot;
|
||||
const [legacy] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT payload_json FROM bill_snapshots WHERE token = ? LIMIT 1`,
|
||||
[token],
|
||||
);
|
||||
const payload = legacy[0]?.payload_json;
|
||||
if (typeof payload === "string" && payload.length > 0) return payload;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const BILL_BODY_LIMIT = 64 * 1024;
|
||||
|
||||
export async function readLimited(request: Request, max: number): Promise<string | null> {
|
||||
const declared = request.headers.get("content-length");
|
||||
if (declared && Number(declared) > max) return null;
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) return "";
|
||||
let total = 0;
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > max) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Last write wins. Equal timestamps: incoming wins. No server row: insert. */
|
||||
export function incomingWins(serverUpdatedAt: Date | null, incomingUpdatedAt: Date): boolean {
|
||||
if (serverUpdatedAt === null) return true;
|
||||
return incomingUpdatedAt.getTime() >= serverUpdatedAt.getTime();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import mysql, { type Pool, type PoolConnection, type RowDataPacket } from "mysql2/promise";
|
||||
|
||||
let pool: Pool | undefined;
|
||||
|
||||
export function getPool(): Pool {
|
||||
if (!pool) {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is not set");
|
||||
const url = new URL(databaseUrl);
|
||||
pool = mysql.createPool({
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: decodeURIComponent(url.pathname.replace(/^\//, "")),
|
||||
connectionLimit: 10,
|
||||
dateStrings: true,
|
||||
timezone: "Z",
|
||||
charset: "utf8mb4",
|
||||
supportBigNumbers: true,
|
||||
multipleStatements: false,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
if (!pool) return;
|
||||
await pool.end();
|
||||
pool = undefined;
|
||||
}
|
||||
|
||||
export async function withTransaction<T>(fn: (conn: PoolConnection) => Promise<T>): Promise<T> {
|
||||
const conn = await getPool().getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const result = await fn(conn);
|
||||
await conn.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
export type { RowDataPacket };
|
||||
@@ -0,0 +1,16 @@
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Bill tokens are UUID v4 from the tablet. */
|
||||
export function isUuid(value: string): boolean {
|
||||
return UUID_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row ids are UUID v4, except shop_settings which the tablet seeds as "default".
|
||||
* CHAR(36) and a tight charset. Values are still bound as parameters.
|
||||
*/
|
||||
const ROW_ID_RE = /^[0-9a-zA-Z_-]{1,36}$/;
|
||||
|
||||
export function isRowId(value: string): boolean {
|
||||
return ROW_ID_RE.test(value);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function money(n: number): string {
|
||||
const v = Number(n || 0);
|
||||
return "Rs " + v.toLocaleString("en-LK", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Digits only. Drops a leading 0 or 91 so the same mobile is one customer. */
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const WINDOW_MS = 60_000;
|
||||
const LIMIT = 10;
|
||||
const buckets = new Map<string, number[]>();
|
||||
|
||||
export function resetRateLimit(): void {
|
||||
buckets.clear();
|
||||
}
|
||||
|
||||
/** True when the caller is still inside 10 requests per minute. */
|
||||
export function allowRequest(ip: string, now = Date.now()): boolean {
|
||||
const fresh = (buckets.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
|
||||
if (fresh.length >= LIMIT) {
|
||||
buckets.set(ip, fresh);
|
||||
return false;
|
||||
}
|
||||
fresh.push(now);
|
||||
buckets.set(ip, fresh);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Last hop. Apache appends the real client after any address the browser sent. */
|
||||
export function clientIp(header: string | null): string {
|
||||
if (!header) return "direct";
|
||||
const parts = header.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
return parts[parts.length - 1] || "direct";
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
export type ColumnType = "uuid" | "string" | "longtext" | "bool" | "double" | "int" | "datetime";
|
||||
|
||||
export type Column = {
|
||||
name: string;
|
||||
type: ColumnType;
|
||||
nullable: boolean;
|
||||
hasDefault: boolean;
|
||||
max?: number;
|
||||
};
|
||||
|
||||
export type TableSpec = {
|
||||
name: string;
|
||||
columns: Column[];
|
||||
};
|
||||
|
||||
function col(
|
||||
name: string,
|
||||
type: ColumnType,
|
||||
extra?: Partial<Pick<Column, "nullable" | "hasDefault" | "max">>,
|
||||
): Column {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
nullable: extra?.nullable ?? false,
|
||||
hasDefault: extra?.hasDefault ?? false,
|
||||
max: extra?.max,
|
||||
};
|
||||
}
|
||||
|
||||
function tail(): Column[] {
|
||||
return [
|
||||
col("updated_at", "datetime"),
|
||||
col("deleted_at", "datetime", { nullable: true, hasDefault: true }),
|
||||
col("is_dirty", "bool", { hasDefault: true }),
|
||||
];
|
||||
}
|
||||
|
||||
const id = () => col("id", "uuid");
|
||||
const text = (name: string, max: number, hasDefault = false) =>
|
||||
col(name, "string", { max, hasDefault });
|
||||
const ref = (name: string, nullable = false) => col(name, "uuid", { nullable });
|
||||
|
||||
export const SYNC_TABLES: Record<string, TableSpec> = {
|
||||
staff_members: {
|
||||
name: "staff_members",
|
||||
columns: [
|
||||
id(),
|
||||
text("name", 255),
|
||||
text("pin_hash", 255),
|
||||
text("role", 64),
|
||||
col("active", "bool", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
shop_settings: {
|
||||
name: "shop_settings",
|
||||
columns: [
|
||||
id(),
|
||||
text("name", 255),
|
||||
text("address", 4000, true),
|
||||
text("phone", 64, true),
|
||||
text("gstin", 64, true),
|
||||
col("tax_percent", "double", { hasDefault: true }),
|
||||
text("sms_bill_template", 4000),
|
||||
text("sms_status_template", 4000),
|
||||
// v1 does not allocate invoice numbers. next_invoice_number is last-write-wins.
|
||||
// Two tablets incrementing it will clash.
|
||||
col("next_invoice_number", "int", { hasDefault: true }),
|
||||
text("expected_open", 16, true),
|
||||
text("expected_close", 16, true),
|
||||
col("late_grace_minutes", "int", { hasDefault: true }),
|
||||
text("public_base_url", 512),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
work_days: {
|
||||
name: "work_days",
|
||||
columns: [
|
||||
id(),
|
||||
text("day_key", 32),
|
||||
col("started_at", "datetime"),
|
||||
col("ended_at", "datetime", { nullable: true }),
|
||||
ref("started_by_staff_id"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
attendance_records: {
|
||||
name: "attendance_records",
|
||||
columns: [
|
||||
id(),
|
||||
ref("work_day_id"),
|
||||
ref("staff_id"),
|
||||
col("check_in_at", "datetime", { nullable: true }),
|
||||
col("check_out_at", "datetime", { nullable: true }),
|
||||
text("notes", 4000, true),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
short_leaves: {
|
||||
name: "short_leaves",
|
||||
columns: [
|
||||
id(),
|
||||
ref("work_day_id"),
|
||||
ref("staff_id"),
|
||||
col("from_at", "datetime"),
|
||||
col("to_at", "datetime"),
|
||||
col("minutes", "int"),
|
||||
text("reason", 4000, true),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
overtime_logs: {
|
||||
name: "overtime_logs",
|
||||
columns: [
|
||||
id(),
|
||||
ref("work_day_id"),
|
||||
ref("staff_id"),
|
||||
col("from_at", "datetime", { nullable: true }),
|
||||
col("to_at", "datetime", { nullable: true }),
|
||||
col("minutes", "int"),
|
||||
text("reason", 4000, true),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
customers: {
|
||||
name: "customers",
|
||||
columns: [
|
||||
id(),
|
||||
text("phone", 64),
|
||||
text("name", 255),
|
||||
col("created_at", "datetime", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
vehicles: {
|
||||
name: "vehicles",
|
||||
columns: [
|
||||
id(),
|
||||
ref("customer_id"),
|
||||
text("registration", 64),
|
||||
text("vin", 64, true),
|
||||
text("make", 255, true),
|
||||
text("model_name", 255, true),
|
||||
col("year", "int", { nullable: true }),
|
||||
col("engine_capacity", "int", { nullable: true }),
|
||||
ref("bike_model_id", true),
|
||||
col("created_at", "datetime", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
job_cards: {
|
||||
name: "job_cards",
|
||||
columns: [
|
||||
id(),
|
||||
ref("vehicle_id"),
|
||||
ref("customer_id"),
|
||||
ref("brought_by_customer_id", true),
|
||||
text("complaint", 4000),
|
||||
text("stage", 64),
|
||||
text("engineer_notes", 4000, true),
|
||||
col("created_at", "datetime", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
odo_readings: {
|
||||
name: "odo_readings",
|
||||
columns: [id(), col("read_at", "datetime"), ref("vehicle_id"), col("reading_km", "int"), ...tail()],
|
||||
},
|
||||
job_card_events: {
|
||||
name: "job_card_events",
|
||||
columns: [
|
||||
id(),
|
||||
ref("job_card_id"),
|
||||
text("stage", 64),
|
||||
text("note", 4000, true),
|
||||
col("at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
part_types: {
|
||||
name: "part_types",
|
||||
columns: [id(), text("name", 255), ...tail()],
|
||||
},
|
||||
bike_models: {
|
||||
name: "bike_models",
|
||||
columns: [id(), text("name", 255), text("brand", 255, true), ...tail()],
|
||||
},
|
||||
bike_groups: {
|
||||
name: "bike_groups",
|
||||
columns: [
|
||||
id(),
|
||||
text("name", 255),
|
||||
ref("part_type_id", true),
|
||||
text("code", 64, true),
|
||||
text("notes", 4000, true),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
bike_group_members: {
|
||||
name: "bike_group_members",
|
||||
columns: [id(), ref("group_id"), ref("bike_model_id"), ...tail()],
|
||||
},
|
||||
catalog_parts: {
|
||||
name: "catalog_parts",
|
||||
columns: [
|
||||
id(),
|
||||
ref("part_type_id"),
|
||||
ref("bike_model_id", true),
|
||||
ref("group_id", true),
|
||||
col("is_universal", "bool", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
catalog_part_models: {
|
||||
name: "catalog_part_models",
|
||||
columns: [id(), ref("catalog_part_id"), ref("bike_model_id"), ...tail()],
|
||||
},
|
||||
part_variants: {
|
||||
name: "part_variants",
|
||||
columns: [
|
||||
id(),
|
||||
ref("catalog_part_id"),
|
||||
text("grade", 64),
|
||||
text("brand", 255, true),
|
||||
text("sku", 128, true),
|
||||
col("cost", "double", { hasDefault: true }),
|
||||
col("sale_price", "double"),
|
||||
col("qty_on_hand", "double", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
job_line_items: {
|
||||
name: "job_line_items",
|
||||
columns: [
|
||||
id(),
|
||||
ref("job_card_id"),
|
||||
text("kind", 64),
|
||||
ref("variant_id", true),
|
||||
text("description", 4000),
|
||||
col("qty", "double"),
|
||||
col("unit_price", "double"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
invoices: {
|
||||
name: "invoices",
|
||||
columns: [
|
||||
id(),
|
||||
text("number", 64),
|
||||
ref("public_token"),
|
||||
ref("job_card_id", true),
|
||||
ref("customer_id", true),
|
||||
text("kind", 64),
|
||||
col("subtotal", "double"),
|
||||
col("tax_percent", "double"),
|
||||
col("tax_amount", "double"),
|
||||
col("total", "double"),
|
||||
col("snapshot_json", "longtext"),
|
||||
text("publish_status", 64),
|
||||
col("created_at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
invoice_lines: {
|
||||
name: "invoice_lines",
|
||||
columns: [
|
||||
id(),
|
||||
ref("invoice_id"),
|
||||
text("description", 4000),
|
||||
col("qty", "double"),
|
||||
col("unit_price", "double"),
|
||||
col("line_total", "double"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
payments: {
|
||||
name: "payments",
|
||||
columns: [
|
||||
id(),
|
||||
ref("invoice_id"),
|
||||
text("method", 64),
|
||||
col("amount", "double"),
|
||||
col("at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
demand_logs: {
|
||||
name: "demand_logs",
|
||||
columns: [
|
||||
id(),
|
||||
ref("part_type_id"),
|
||||
ref("bike_model_id"),
|
||||
ref("variant_id", true),
|
||||
col("qty", "double"),
|
||||
text("reason", 4000, true),
|
||||
col("created_at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
sms_outbox: {
|
||||
name: "sms_outbox",
|
||||
columns: [
|
||||
id(),
|
||||
text("to_phone", 64),
|
||||
text("body", 4000),
|
||||
text("status", 64),
|
||||
text("error", 4000, true),
|
||||
ref("invoice_id", true),
|
||||
ref("job_card_id", true),
|
||||
col("created_at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
publish_outbox: {
|
||||
name: "publish_outbox",
|
||||
columns: [
|
||||
id(),
|
||||
ref("invoice_id"),
|
||||
ref("public_token"),
|
||||
col("payload_json", "longtext"),
|
||||
text("status", 64),
|
||||
text("error", 4000, true),
|
||||
col("created_at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SYNC_TABLE_NAMES = Object.keys(SYNC_TABLES);
|
||||
@@ -0,0 +1,4 @@
|
||||
export function quoteIdent(name: string): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(name)) throw new Error("invalid identifier");
|
||||
return `\`${name}\``;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import { getPool } from "./db";
|
||||
import { normalizePhone } from "./phone";
|
||||
import { sqlDateToIso } from "./time";
|
||||
import { NO_BIKE, pickWorkshopJob, stageLabel, type BikeStatus, type StatusResult } from "./workshop";
|
||||
|
||||
export async function lookupBikes(rawPhone: string): Promise<StatusResult> {
|
||||
const phone = normalizePhone(rawPhone);
|
||||
if (!phone) return { message: NO_BIKE, error: null, bikes: [] };
|
||||
|
||||
const pool = getPool();
|
||||
const [customers] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id FROM customers WHERE phone = ? AND deleted_at IS NULL`,
|
||||
[phone],
|
||||
);
|
||||
if (customers.length === 0) return { message: NO_BIKE, error: null, bikes: [] };
|
||||
|
||||
const customerIds = customers.map((row) => String(row.id));
|
||||
const [vehicles] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, registration, make, model_name FROM vehicles
|
||||
WHERE customer_id IN (${customerIds.map(() => "?").join(", ")}) AND deleted_at IS NULL
|
||||
ORDER BY registration ASC, id ASC`,
|
||||
customerIds,
|
||||
);
|
||||
if (vehicles.length === 0) return { message: NO_BIKE, error: null, bikes: [] };
|
||||
|
||||
const bikes: BikeStatus[] = [];
|
||||
for (const vehicle of vehicles) {
|
||||
const [jobs] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, stage, created_at FROM job_cards WHERE vehicle_id = ? AND deleted_at IS NULL`,
|
||||
[vehicle.id],
|
||||
);
|
||||
const picked = pickWorkshopJob(
|
||||
jobs.map((job) => ({
|
||||
id: String(job.id),
|
||||
stage: String(job.stage),
|
||||
createdAt: sqlDateToIso(job.created_at) ?? "",
|
||||
})),
|
||||
);
|
||||
const registration = String(vehicle.registration ?? "");
|
||||
const make = String(vehicle.make ?? "");
|
||||
const model = String(vehicle.model_name ?? "");
|
||||
if (!picked) {
|
||||
bikes.push({
|
||||
registration,
|
||||
make,
|
||||
model,
|
||||
inWorkshop: false,
|
||||
stageLabel: null,
|
||||
stageAt: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const [events] = await pool.query<RowDataPacket[]>(
|
||||
"SELECT `at` FROM job_card_events WHERE job_card_id = ? AND deleted_at IS NULL ORDER BY `at` DESC, id DESC LIMIT 1",
|
||||
[picked.id],
|
||||
);
|
||||
bikes.push({
|
||||
registration,
|
||||
make,
|
||||
model,
|
||||
inWorkshop: true,
|
||||
stageLabel: stageLabel(picked.stage),
|
||||
stageAt: events[0] ? sqlDateToIso(events[0].at) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return { message: null, error: null, bikes };
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import { incomingWins } from "./conflict";
|
||||
import { getPool, withTransaction } from "./db";
|
||||
import { isRowId } from "./ids";
|
||||
import { SYNC_TABLES, SYNC_TABLE_NAMES, type Column, type TableSpec } from "./schema";
|
||||
import { quoteIdent } from "./sql";
|
||||
import { isoToSql, sqlDateToIso } from "./time";
|
||||
|
||||
export class SyncInputError extends Error {}
|
||||
|
||||
export type PushResult = {
|
||||
accepted: Record<string, string[]>;
|
||||
rejected: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type Cursor = { updatedAt: string; id: string };
|
||||
|
||||
export type PullResult = {
|
||||
tables: Record<string, Record<string, unknown>[]>;
|
||||
nextCursors: Record<string, Cursor>;
|
||||
};
|
||||
|
||||
export const EPOCH_ISO = "1970-01-01T00:00:00.000Z";
|
||||
export const PULL_LIMIT = 500;
|
||||
|
||||
type SqlValue = string | number | null;
|
||||
|
||||
export type PreparedRow = {
|
||||
id: string;
|
||||
updatedAt: Date;
|
||||
columns: string[];
|
||||
values: SqlValue[];
|
||||
};
|
||||
|
||||
export function prepareRow(
|
||||
table: TableSpec,
|
||||
raw: unknown,
|
||||
): { ok: true; row: PreparedRow } | { ok: false; reason: "skip" } | { ok: false; reason: "reject"; id: string } {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, reason: "skip" };
|
||||
const record = raw as Record<string, unknown>;
|
||||
if (typeof record.id !== "string" || record.id.length > 36) return { ok: false, reason: "skip" };
|
||||
if (!isRowId(record.id)) return { ok: false, reason: "reject", id: record.id };
|
||||
|
||||
const columns: string[] = [];
|
||||
const values: SqlValue[] = [];
|
||||
for (const column of table.columns) {
|
||||
if (column.name === "id" || column.name === "is_dirty") continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(record, column.name)) {
|
||||
if (column.nullable) {
|
||||
columns.push(column.name);
|
||||
values.push(null);
|
||||
continue;
|
||||
}
|
||||
if (column.hasDefault) continue;
|
||||
return { ok: false, reason: "reject", id: record.id };
|
||||
}
|
||||
const coerced = coerce(column, record[column.name]);
|
||||
if (!coerced.ok) return { ok: false, reason: "reject", id: record.id };
|
||||
columns.push(column.name);
|
||||
values.push(coerced.value);
|
||||
}
|
||||
|
||||
const updatedIndex = columns.indexOf("updated_at");
|
||||
if (updatedIndex < 0 || typeof values[updatedIndex] !== "string") {
|
||||
return { ok: false, reason: "reject", id: record.id };
|
||||
}
|
||||
const updatedAt = new Date(`${(values[updatedIndex] as string).replace(" ", "T")}Z`);
|
||||
if (Number.isNaN(updatedAt.getTime())) return { ok: false, reason: "reject", id: record.id };
|
||||
return { ok: true, row: { id: record.id, updatedAt, columns, values } };
|
||||
}
|
||||
|
||||
function coerce(column: Column, value: unknown): { ok: true; value: SqlValue } | { ok: false } {
|
||||
if (value === null) {
|
||||
if (column.nullable) return { ok: true, value: null };
|
||||
return { ok: false };
|
||||
}
|
||||
switch (column.type) {
|
||||
case "uuid": {
|
||||
if (typeof value !== "string" || !isRowId(value)) return { ok: false };
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "string":
|
||||
case "longtext": {
|
||||
let text: string;
|
||||
if (typeof value === "string") text = value;
|
||||
else if (column.type === "longtext" && value && typeof value === "object") text = JSON.stringify(value);
|
||||
else return { ok: false };
|
||||
if (column.max !== undefined && text.length > column.max) return { ok: false };
|
||||
return { ok: true, value: text };
|
||||
}
|
||||
case "bool": {
|
||||
if (value === true || value === 1) return { ok: true, value: 1 };
|
||||
if (value === false || value === 0) return { ok: true, value: 0 };
|
||||
return { ok: false };
|
||||
}
|
||||
case "double": {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return { ok: false };
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "int": {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) return { ok: false };
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "datetime": {
|
||||
if (typeof value !== "string") return { ok: false };
|
||||
const sql = isoToSql(value);
|
||||
if (!sql) return { ok: false };
|
||||
return { ok: true, value: sql };
|
||||
}
|
||||
default:
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushChanges(tables: unknown): Promise<PushResult> {
|
||||
// v1 does not allocate invoice numbers. shop_settings.next_invoice_number is
|
||||
// last-write-wins like every other column. Two tablets incrementing it will clash.
|
||||
if (!tables || typeof tables !== "object" || Array.isArray(tables)) {
|
||||
throw new SyncInputError("invalid body");
|
||||
}
|
||||
const accepted: Record<string, string[]> = {};
|
||||
const rejected: Record<string, string[]> = {};
|
||||
|
||||
await withTransaction(async (conn) => {
|
||||
for (const [name, rows] of Object.entries(tables as Record<string, unknown>)) {
|
||||
const table = SYNC_TABLES[name];
|
||||
if (!table || !Array.isArray(rows)) continue;
|
||||
accepted[name] = [];
|
||||
rejected[name] = [];
|
||||
for (const raw of rows) {
|
||||
const prepared = prepareRow(table, raw);
|
||||
if (!prepared.ok) {
|
||||
if (prepared.reason === "reject") rejected[name].push(prepared.id);
|
||||
continue;
|
||||
}
|
||||
const [found] = await conn.query<RowDataPacket[]>(
|
||||
`SELECT updated_at FROM ${quoteIdent(table.name)} WHERE id = ? FOR UPDATE`,
|
||||
[prepared.row.id],
|
||||
);
|
||||
const existing = found[0];
|
||||
if (existing) {
|
||||
const serverIso = sqlDateToIso(existing.updated_at);
|
||||
const serverDate = serverIso ? new Date(serverIso) : null;
|
||||
if (serverDate && !incomingWins(serverDate, prepared.row.updatedAt)) {
|
||||
rejected[name].push(prepared.row.id);
|
||||
continue;
|
||||
}
|
||||
const sets = prepared.row.columns.map((column) => `${quoteIdent(column)} = ?`);
|
||||
sets.push(`${quoteIdent("is_dirty")} = 0`);
|
||||
await conn.query(
|
||||
`UPDATE ${quoteIdent(table.name)} SET ${sets.join(", ")} WHERE id = ?`,
|
||||
[...prepared.row.values, prepared.row.id],
|
||||
);
|
||||
} else {
|
||||
const insertCols = ["id", ...prepared.row.columns, "is_dirty"];
|
||||
const insertVals: SqlValue[] = [prepared.row.id, ...prepared.row.values, 0];
|
||||
await conn.query(
|
||||
`INSERT INTO ${quoteIdent(table.name)} (${insertCols.map((column) => quoteIdent(column)).join(", ")}) VALUES (${insertCols.map(() => "?").join(", ")})`,
|
||||
insertVals,
|
||||
);
|
||||
}
|
||||
accepted[name].push(prepared.row.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
export function resolveCursors(input: unknown): {
|
||||
cursors: Record<string, { updatedAt: Date; id: string }>;
|
||||
error?: string;
|
||||
} {
|
||||
const source = input === undefined ? {} : input;
|
||||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||
return { cursors: {}, error: "invalid cursors" };
|
||||
}
|
||||
const raw = source as Record<string, unknown>;
|
||||
const cursors: Record<string, { updatedAt: Date; id: string }> = {};
|
||||
for (const name of SYNC_TABLE_NAMES) {
|
||||
if (!Object.prototype.hasOwnProperty.call(raw, name)) {
|
||||
cursors[name] = { updatedAt: new Date(EPOCH_ISO), id: "" };
|
||||
continue;
|
||||
}
|
||||
const parsed = parseCursor(raw[name]);
|
||||
if (!parsed) return { cursors: {}, error: "invalid cursor" };
|
||||
cursors[name] = parsed;
|
||||
}
|
||||
return { cursors };
|
||||
}
|
||||
|
||||
function parseCursor(value: unknown): { updatedAt: Date; id: string } | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.updatedAt !== "string") return null;
|
||||
const updatedAt = new Date(record.updatedAt);
|
||||
if (Number.isNaN(updatedAt.getTime())) return null;
|
||||
const id = record.id === undefined ? "" : record.id;
|
||||
if (typeof id !== "string") return null;
|
||||
return { updatedAt, id };
|
||||
}
|
||||
|
||||
function serializeValue(column: Column, value: unknown): unknown {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (column.type === "datetime") return sqlDateToIso(value);
|
||||
if (column.type === "bool") {
|
||||
if (column.name === "is_dirty") return 0;
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
if (column.type === "double" || column.type === "int") {
|
||||
return typeof value === "number" ? value : Number(value);
|
||||
}
|
||||
if (Buffer.isBuffer(value)) return value.toString("utf8");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export async function pullChanges(cursorsInput: unknown): Promise<PullResult> {
|
||||
const resolved = resolveCursors(cursorsInput);
|
||||
if (resolved.error) throw new SyncInputError(resolved.error);
|
||||
|
||||
const pool = getPool();
|
||||
const tables: Record<string, Record<string, unknown>[]> = {};
|
||||
const nextCursors: Record<string, Cursor> = {};
|
||||
|
||||
for (const name of SYNC_TABLE_NAMES) {
|
||||
const table = SYNC_TABLES[name];
|
||||
const cursor = resolved.cursors[name];
|
||||
const updatedSql = isoToSql(cursor.updatedAt.toISOString());
|
||||
if (!updatedSql) throw new SyncInputError("invalid cursor");
|
||||
const selectList = table.columns.map((column) => quoteIdent(column.name)).join(", ");
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT ${selectList} FROM ${quoteIdent(name)}
|
||||
WHERE updated_at > ? OR (updated_at = ? AND id > ?)
|
||||
ORDER BY updated_at ASC, id ASC
|
||||
LIMIT ${PULL_LIMIT}`,
|
||||
[updatedSql, updatedSql, cursor.id],
|
||||
);
|
||||
const serialized = rows.map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const column of table.columns) out[column.name] = serializeValue(column, row[column.name]);
|
||||
return out;
|
||||
});
|
||||
tables[name] = serialized;
|
||||
const last = serialized[serialized.length - 1];
|
||||
if (last && typeof last.updated_at === "string" && typeof last.id === "string") {
|
||||
nextCursors[name] = { updatedAt: last.updated_at, id: last.id };
|
||||
} else {
|
||||
nextCursors[name] = { updatedAt: cursor.updatedAt.toISOString(), id: cursor.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { tables, nextCursors };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function isoToSql(iso: string): string | null {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString().slice(0, 23).replace("T", " ");
|
||||
}
|
||||
|
||||
export function sqlDateToIso(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return null;
|
||||
return value.toISOString();
|
||||
}
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
if (/[zZ]$/.test(raw) || /[+-]\d{2}:?\d{2}$/.test(raw)) {
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
const normalized = raw.includes("T") ? raw : raw.replace(" ", "T");
|
||||
const withMs = /\.\d{1,3}$/.test(normalized) ? normalized : `${normalized}.000`;
|
||||
const date = new Date(`${withMs}Z`);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const NO_BIKE = "No bike found for this number.";
|
||||
export const NOT_IN_WORKSHOP = "This bike is not in the workshop.";
|
||||
|
||||
export const STAGE_LABELS: Record<string, string> = {
|
||||
received: "Received",
|
||||
diagnostic: "Diagnostic",
|
||||
in_repair: "In Repair",
|
||||
quality_check: "Quality Check",
|
||||
ready: "Ready",
|
||||
billed: "Billed",
|
||||
};
|
||||
|
||||
export function stageLabel(stage: string): string {
|
||||
return STAGE_LABELS[stage] ?? stage;
|
||||
}
|
||||
|
||||
export type JobPick = {
|
||||
id: string;
|
||||
stage: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** Newest non-deleted job that is not billed. Caller already dropped deleted rows. */
|
||||
export function pickWorkshopJob(jobs: JobPick[]): JobPick | null {
|
||||
const open = jobs.filter((job) => job.stage !== "billed");
|
||||
open.sort((a, b) => {
|
||||
const byTime = b.createdAt.localeCompare(a.createdAt);
|
||||
if (byTime !== 0) return byTime;
|
||||
return b.id.localeCompare(a.id);
|
||||
});
|
||||
return open[0] ?? null;
|
||||
}
|
||||
|
||||
export type BikeStatus = {
|
||||
registration: string;
|
||||
make: string;
|
||||
model: string;
|
||||
inWorkshop: boolean;
|
||||
stageLabel: string | null;
|
||||
stageAt: string | null;
|
||||
};
|
||||
|
||||
export type StatusResult = {
|
||||
message: string | null;
|
||||
error: string | null;
|
||||
bikes: BikeStatus[];
|
||||
};
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["mysql2"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1634
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "hundredkmph-web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -H 127.0.0.1 -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -H 127.0.0.1 -p 3000",
|
||||
"test": "tsx --test tests/unit.test.ts tests/sync.integration.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "^3.14.3",
|
||||
"next": "^15.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.11",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, test } from "node:test";
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import { readBillPayload, storePublishedBill } from "../lib/bills";
|
||||
import { closePool, getPool } from "../lib/db";
|
||||
import { SYNC_TABLE_NAMES } from "../lib/schema";
|
||||
import { lookupBikes } from "../lib/status";
|
||||
import { EPOCH_ISO, PULL_LIMIT, pullChanges, pushChanges } from "../lib/sync";
|
||||
import { NO_BIKE, NOT_IN_WORKSHOP } from "../lib/workshop";
|
||||
|
||||
const hasDb = Boolean(process.env.DATABASE_URL);
|
||||
|
||||
after(async () => {
|
||||
if (hasDb) await closePool();
|
||||
});
|
||||
|
||||
function statements(sql: string): string[] {
|
||||
return sql
|
||||
.split("\n")
|
||||
.filter((line) => !line.trim().startsWith("--"))
|
||||
.join("\n")
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function ensureSchema(): Promise<void> {
|
||||
const sql = readFileSync("db/001_init.sql", "utf8");
|
||||
const pool = getPool();
|
||||
for (const statement of statements(sql)) {
|
||||
await pool.query(statement);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(table: string, id: string): Promise<void> {
|
||||
try {
|
||||
await getPool().query(`DELETE FROM \`${table}\` WHERE id = ?`, [id]);
|
||||
} catch {
|
||||
// The deployed app user has no DELETE. Leftover rows stay soft-deleted.
|
||||
}
|
||||
}
|
||||
|
||||
function customerCursors(cursor: { updatedAt: string; id: string }) {
|
||||
const future = { updatedAt: "9999-01-01T00:00:00.000Z", id: "" };
|
||||
const cursors: Record<string, { updatedAt: string; id: string }> = {};
|
||||
for (const name of SYNC_TABLE_NAMES) cursors[name] = { ...future };
|
||||
cursors.customers = cursor;
|
||||
return cursors;
|
||||
}
|
||||
|
||||
async function findCustomer(id: string): Promise<Record<string, unknown> | undefined> {
|
||||
let cursor = { updatedAt: EPOCH_ISO, id: "" };
|
||||
for (let page = 0; page < 50; page += 1) {
|
||||
const result = await pullChanges({ cursors: customerCursors(cursor) });
|
||||
const row = result.tables.customers.find((item) => item.id === id);
|
||||
if (row) return row;
|
||||
if (result.tables.customers.length < PULL_LIMIT) return undefined;
|
||||
const next = result.nextCursors.customers;
|
||||
if (next.updatedAt === cursor.updatedAt && next.id === cursor.id) return undefined;
|
||||
cursor = next;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
test("push pull soft-delete and an older push", { skip: !hasDb }, async () => {
|
||||
await ensureSchema();
|
||||
const id = randomUUID();
|
||||
const t1 = "2026-09-28T06:30:00.000Z";
|
||||
const t2 = "2026-09-28T07:30:00.000Z";
|
||||
const base = {
|
||||
id,
|
||||
phone: "771234567",
|
||||
name: "Ada",
|
||||
created_at: t1,
|
||||
updated_at: t1,
|
||||
deleted_at: null,
|
||||
is_dirty: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await pushChanges({ customers: [base] });
|
||||
assert.deepEqual(created.accepted.customers, [id]);
|
||||
assert.deepEqual(created.rejected.customers, []);
|
||||
|
||||
const pulled = await findCustomer(id);
|
||||
assert.ok(pulled);
|
||||
assert.equal(pulled.name, "Ada");
|
||||
assert.equal(pulled.deleted_at, null);
|
||||
assert.equal(pulled.is_dirty, 0);
|
||||
assert.equal(pulled.phone, "771234567");
|
||||
|
||||
const removed = await pushChanges({
|
||||
customers: [{ ...base, updated_at: t2, deleted_at: t2 }],
|
||||
});
|
||||
assert.deepEqual(removed.accepted.customers, [id]);
|
||||
|
||||
const deleted = await findCustomer(id);
|
||||
assert.ok(deleted);
|
||||
assert.equal(deleted.name, "Ada");
|
||||
assert.equal(deleted.deleted_at, t2);
|
||||
|
||||
const older = await pushChanges({
|
||||
customers: [{ ...base, name: "Old", updated_at: t1, deleted_at: null }],
|
||||
});
|
||||
assert.deepEqual(older.rejected.customers, [id]);
|
||||
assert.deepEqual(older.accepted.customers, []);
|
||||
|
||||
const still = await findCustomer(id);
|
||||
assert.equal(still?.name, "Ada");
|
||||
assert.equal(still?.deleted_at, t2);
|
||||
} finally {
|
||||
await remove("customers", id);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy bill publish does not insert invoices.id = token", { skip: !hasDb }, async () => {
|
||||
await ensureSchema();
|
||||
const token = randomUUID();
|
||||
const invoiceId = randomUUID();
|
||||
const t1 = "2026-09-28T06:30:00.000Z";
|
||||
const body = JSON.stringify({
|
||||
shopName: "100kmph",
|
||||
address: "Service Centre",
|
||||
phone: "",
|
||||
number: "INV-0001",
|
||||
token,
|
||||
createdAt: t1,
|
||||
kind: "job",
|
||||
customerName: "Ada",
|
||||
customerPhone: "771234567",
|
||||
vehicleReg: "ABC-1234",
|
||||
vehicleModel: "CBR",
|
||||
lines: [{ description: "Labour", qty: 1, unitPrice: 500, lineTotal: 500 }],
|
||||
subtotal: 500,
|
||||
total: 500,
|
||||
paymentMethod: "cash",
|
||||
});
|
||||
|
||||
try {
|
||||
await storePublishedBill(token, body);
|
||||
assert.equal(await readBillPayload(token), body);
|
||||
const [byId] = await getPool().query<RowDataPacket[]>("SELECT id FROM invoices WHERE id = ?", [token]);
|
||||
assert.equal(byId.length, 0);
|
||||
|
||||
await pushChanges({
|
||||
invoices: [
|
||||
{
|
||||
id: invoiceId,
|
||||
number: "INV-0001",
|
||||
public_token: token,
|
||||
job_card_id: null,
|
||||
customer_id: null,
|
||||
kind: "job",
|
||||
subtotal: 500,
|
||||
tax_percent: 0,
|
||||
tax_amount: 0,
|
||||
total: 500,
|
||||
snapshot_json: "{}",
|
||||
publish_status: "pending",
|
||||
created_at: t1,
|
||||
updated_at: t1,
|
||||
deleted_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(await readBillPayload(token), "{}");
|
||||
|
||||
await storePublishedBill(token, body);
|
||||
assert.equal(await readBillPayload(token), body);
|
||||
const [rows] = await getPool().query<RowDataPacket[]>(
|
||||
"SELECT id, snapshot_json, updated_at FROM invoices WHERE public_token = ?",
|
||||
[token],
|
||||
);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].id, invoiceId);
|
||||
assert.equal(rows[0].snapshot_json, body);
|
||||
const updated = rows[0].updated_at instanceof Date
|
||||
? rows[0].updated_at.toISOString()
|
||||
: String(rows[0].updated_at).replace(" ", "T").replace(/Z?$/, "Z");
|
||||
assert.equal(updated.startsWith("2026-09-28T06:30:00"), true);
|
||||
} finally {
|
||||
try {
|
||||
await getPool().query("DELETE FROM bill_snapshots WHERE token = ?", [token]);
|
||||
} catch {
|
||||
// no DELETE grant
|
||||
}
|
||||
await remove("invoices", invoiceId);
|
||||
}
|
||||
});
|
||||
|
||||
test("status lookup stays on one customer", { skip: !hasDb }, async () => {
|
||||
await ensureSchema();
|
||||
const t = "2026-09-28T06:30:00.000Z";
|
||||
const customerId = randomUUID();
|
||||
const otherId = randomUUID();
|
||||
const vehicleId = randomUUID();
|
||||
const billedVehicleId = randomUUID();
|
||||
const otherVehicleId = randomUUID();
|
||||
const jobId = randomUUID();
|
||||
const billedJobId = randomUUID();
|
||||
const eventId = randomUUID();
|
||||
|
||||
const vehicle = (id: string, customer: string, registration: string) => ({
|
||||
id,
|
||||
customer_id: customer,
|
||||
registration,
|
||||
vin: "",
|
||||
make: "Honda",
|
||||
model_name: "CBR",
|
||||
year: null,
|
||||
engine_capacity: null,
|
||||
bike_model_id: null,
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
deleted_at: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await pushChanges({
|
||||
customers: [
|
||||
{ id: customerId, phone: "0771234567", name: "Ada", created_at: t, updated_at: t, deleted_at: null },
|
||||
{ id: otherId, phone: "779999999", name: "Bea", created_at: t, updated_at: t, deleted_at: null },
|
||||
],
|
||||
vehicles: [
|
||||
vehicle(vehicleId, customerId, "ABC-1234"),
|
||||
vehicle(billedVehicleId, customerId, "OLD-1"),
|
||||
vehicle(otherVehicleId, otherId, "SECRET-999"),
|
||||
],
|
||||
job_cards: [
|
||||
{
|
||||
id: jobId,
|
||||
vehicle_id: vehicleId,
|
||||
customer_id: customerId,
|
||||
brought_by_customer_id: null,
|
||||
complaint: "DO NOT LEAK COMPLAINT",
|
||||
stage: "in_repair",
|
||||
engineer_notes: "DO NOT LEAK NOTES",
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
deleted_at: null,
|
||||
},
|
||||
{
|
||||
id: billedJobId,
|
||||
vehicle_id: billedVehicleId,
|
||||
customer_id: customerId,
|
||||
brought_by_customer_id: null,
|
||||
complaint: "DO NOT LEAK COMPLAINT",
|
||||
stage: "billed",
|
||||
engineer_notes: "DO NOT LEAK NOTES",
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
deleted_at: null,
|
||||
},
|
||||
],
|
||||
job_card_events: [
|
||||
{
|
||||
id: eventId,
|
||||
job_card_id: jobId,
|
||||
stage: "in_repair",
|
||||
note: "DO NOT LEAK EVENT NOTE",
|
||||
at: t,
|
||||
updated_at: t,
|
||||
deleted_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await lookupBikes("077 123 4567");
|
||||
assert.equal(result.error, null);
|
||||
assert.equal(result.message, null);
|
||||
assert.equal(result.bikes.length, 2);
|
||||
assert.equal(result.bikes[0].registration, "ABC-1234");
|
||||
assert.equal(result.bikes[0].make, "Honda");
|
||||
assert.equal(result.bikes[0].model, "CBR");
|
||||
assert.equal(result.bikes[0].inWorkshop, true);
|
||||
assert.equal(result.bikes[0].stageLabel, "In Repair");
|
||||
assert.equal(result.bikes[0].stageAt, t);
|
||||
assert.equal(result.bikes[1].registration, "OLD-1");
|
||||
assert.equal(result.bikes[1].inWorkshop, false);
|
||||
|
||||
const dumped = JSON.stringify(result);
|
||||
assert.equal(dumped.includes("SECRET-999"), false);
|
||||
assert.equal(dumped.includes("DO NOT LEAK"), false);
|
||||
assert.equal(dumped.includes("Bea"), false);
|
||||
assert.equal(dumped.includes("pin_hash"), false);
|
||||
assert.equal(dumped.includes(NOT_IN_WORKSHOP), false);
|
||||
|
||||
const unknown = await lookupBikes("0700000000");
|
||||
assert.equal(unknown.message, NO_BIKE);
|
||||
assert.deepEqual(unknown.bikes, []);
|
||||
assert.equal(JSON.stringify(unknown).includes("ABC-1234"), false);
|
||||
} finally {
|
||||
for (const [table, id] of [
|
||||
["job_card_events", eventId],
|
||||
["job_cards", jobId],
|
||||
["job_cards", billedJobId],
|
||||
["vehicles", vehicleId],
|
||||
["vehicles", billedVehicleId],
|
||||
["vehicles", otherVehicleId],
|
||||
["customers", customerId],
|
||||
["customers", otherId],
|
||||
] as const) {
|
||||
await remove(table, id);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { isAuthorized } from "../lib/auth";
|
||||
import { readLimited } from "../lib/body";
|
||||
import { incomingWins } from "../lib/conflict";
|
||||
import { isRowId, isUuid } from "../lib/ids";
|
||||
import { money } from "../lib/money";
|
||||
import { normalizePhone } from "../lib/phone";
|
||||
import { allowRequest, clientIp, resetRateLimit } from "../lib/rate-limit";
|
||||
import { SYNC_TABLES, SYNC_TABLE_NAMES } from "../lib/schema";
|
||||
import { EPOCH_ISO, prepareRow, resolveCursors } from "../lib/sync";
|
||||
import { isoToSql, sqlDateToIso } from "../lib/time";
|
||||
import { pickWorkshopJob, stageLabel } from "../lib/workshop";
|
||||
|
||||
const SPEC_TABLES = [
|
||||
"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",
|
||||
];
|
||||
|
||||
test("normalizePhone matches the tablet", () => {
|
||||
assert.equal(normalizePhone("771234567"), "771234567");
|
||||
assert.equal(normalizePhone("0771234567"), "0771234567");
|
||||
assert.equal(normalizePhone("077 123 4567"), "0771234567");
|
||||
assert.equal(normalizePhone("00771234567"), "0771234567");
|
||||
assert.equal(normalizePhone("917712345678"), "7712345678");
|
||||
assert.equal(normalizePhone(""), "");
|
||||
});
|
||||
|
||||
test("money uses Rs and two fraction digits", () => {
|
||||
assert.equal(money(500), "Rs 500.00");
|
||||
assert.equal(money(0), "Rs 0.00");
|
||||
assert.match(money(1500), /^Rs 1,500\.00$/);
|
||||
});
|
||||
|
||||
test("uuid and row ids", () => {
|
||||
assert.equal(isUuid("3f1c2a90-7b14-4d2e-9c11-6a0b5e8d4f21"), true);
|
||||
assert.equal(isUuid("default"), false);
|
||||
assert.equal(isUuid("not-a-uuid"), false);
|
||||
assert.equal(isRowId("default"), true);
|
||||
assert.equal(isRowId("3f1c2a90-7b14-4d2e-9c11-6a0b5e8d4f21"), true);
|
||||
assert.equal(isRowId(""), false);
|
||||
assert.equal(isRowId("has space"), false);
|
||||
});
|
||||
|
||||
test("conflict rule is last write wins, equal incoming wins", () => {
|
||||
const older = new Date("2026-09-28T06:30:00.000Z");
|
||||
const newer = new Date("2026-09-28T07:30:00.000Z");
|
||||
assert.equal(incomingWins(null, older), true);
|
||||
assert.equal(incomingWins(older, newer), true);
|
||||
assert.equal(incomingWins(older, new Date(older.getTime())), true);
|
||||
assert.equal(incomingWins(newer, older), false);
|
||||
});
|
||||
|
||||
test("timestamps round-trip as UTC", () => {
|
||||
assert.equal(isoToSql("2026-09-28T06:30:00.000Z"), "2026-09-28 06:30:00.000");
|
||||
assert.equal(sqlDateToIso("2026-09-28 06:30:00.000"), "2026-09-28T06:30:00.000Z");
|
||||
assert.equal(isoToSql("not-a-date"), null);
|
||||
});
|
||||
|
||||
test("auth requires the bearer token", () => {
|
||||
const previous = process.env.SYNC_TOKEN;
|
||||
process.env.SYNC_TOKEN = "test-secret-value";
|
||||
try {
|
||||
assert.equal(isAuthorized("Bearer test-secret-value"), true);
|
||||
assert.equal(isAuthorized("Bearer wrong-secret-value"), false);
|
||||
assert.equal(isAuthorized("Bearer test-secret-value extra"), false);
|
||||
assert.equal(isAuthorized("Basic test-secret-value"), false);
|
||||
assert.equal(isAuthorized(null), false);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.SYNC_TOKEN;
|
||||
else process.env.SYNC_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("status rate limit is 10 per minute per address", () => {
|
||||
resetRateLimit();
|
||||
const now = 1_000_000;
|
||||
for (let i = 0; i < 10; i += 1) assert.equal(allowRequest("203.0.113.5", now), true);
|
||||
assert.equal(allowRequest("203.0.113.5", now), false);
|
||||
assert.equal(allowRequest("203.0.113.9", now), true);
|
||||
assert.equal(allowRequest("203.0.113.5", now + 60_000), true);
|
||||
});
|
||||
|
||||
test("client ip is the last forwarded hop", () => {
|
||||
assert.equal(clientIp("1.2.3.4, 203.0.113.5"), "203.0.113.5");
|
||||
assert.equal(clientIp(null), "direct");
|
||||
});
|
||||
|
||||
test("readLimited caps the body", async () => {
|
||||
const small = new Request("http://127.0.0.1/", { method: "POST", body: "abc" });
|
||||
assert.equal(await readLimited(small, 10), "abc");
|
||||
const big = new Request("http://127.0.0.1/", { method: "POST", body: "x".repeat(20) });
|
||||
assert.equal(await readLimited(big, 10), null);
|
||||
});
|
||||
|
||||
test("missing sync cursor is the epoch", () => {
|
||||
const resolved = resolveCursors(undefined);
|
||||
assert.equal(resolved.error, undefined);
|
||||
assert.equal(resolved.cursors.customers.updatedAt.toISOString(), EPOCH_ISO);
|
||||
assert.equal(resolved.cursors.customers.id, "");
|
||||
assert.equal(Object.keys(resolved.cursors).length, SPEC_TABLES.length);
|
||||
|
||||
const partial = resolveCursors({
|
||||
customers: { updatedAt: "2026-09-28T06:30:00.000Z", id: "abc" },
|
||||
});
|
||||
assert.equal(partial.cursors.customers.id, "abc");
|
||||
assert.equal(partial.cursors.invoices.updatedAt.toISOString(), EPOCH_ISO);
|
||||
assert.equal(resolveCursors({ customers: { updatedAt: "nope", id: "" } }).error, "invalid cursor");
|
||||
});
|
||||
|
||||
test("prepareRow stores is_dirty itself and accepts the settings id", () => {
|
||||
const settings = prepareRow(SYNC_TABLES.shop_settings, {
|
||||
id: "default",
|
||||
name: "100kmph",
|
||||
sms_bill_template: "Bill {url}",
|
||||
sms_status_template: "Status",
|
||||
public_base_url: "https://example.test",
|
||||
updated_at: "2026-09-28T06:30:00.000Z",
|
||||
is_dirty: true,
|
||||
});
|
||||
assert.equal(settings.ok, true);
|
||||
if (settings.ok) {
|
||||
assert.equal(settings.row.columns.includes("is_dirty"), false);
|
||||
assert.equal(settings.row.id, "default");
|
||||
}
|
||||
|
||||
const customer = prepareRow(SYNC_TABLES.customers, {
|
||||
id: "3f1c2a90-7b14-4d2e-9c11-6a0b5e8d4f21",
|
||||
phone: "771234567",
|
||||
updated_at: "2026-09-28T06:30:00.000Z",
|
||||
pin_hash: "should-not-stick",
|
||||
});
|
||||
assert.equal(customer.ok, false);
|
||||
});
|
||||
|
||||
test("workshop job prefers the newest open stage", () => {
|
||||
const picked = pickWorkshopJob([
|
||||
{ id: "a", stage: "billed", createdAt: "2026-09-28T08:00:00.000Z" },
|
||||
{ id: "b", stage: "in_repair", createdAt: "2026-09-28T07:00:00.000Z" },
|
||||
{ id: "c", stage: "received", createdAt: "2026-09-28T06:00:00.000Z" },
|
||||
]);
|
||||
assert.equal(picked?.id, "b");
|
||||
assert.equal(
|
||||
pickWorkshopJob([{ id: "a", stage: "billed", createdAt: "2026-09-28T08:00:00.000Z" }]),
|
||||
null,
|
||||
);
|
||||
assert.equal(stageLabel("quality_check"), "Quality Check");
|
||||
assert.equal(stageLabel("in_repair"), "In Repair");
|
||||
});
|
||||
|
||||
test("schema matches the migration", () => {
|
||||
const sql = readFileSync("db/001_init.sql", "utf8");
|
||||
assert.deepEqual(SYNC_TABLE_NAMES, SPEC_TABLES);
|
||||
assert.equal(sql.includes("FOREIGN KEY"), false);
|
||||
assert.match(sql, /Two tablets incrementing this will clash/);
|
||||
assert.match(sql, /CREATE TABLE IF NOT EXISTS bill_snapshots/);
|
||||
|
||||
for (const name of SPEC_TABLES) {
|
||||
const match = sql.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${name} \\(([\\s\\S]*?)\\n\\)`));
|
||||
assert.ok(match, name);
|
||||
const body = match[1];
|
||||
assert.match(body, /updated_at, id/);
|
||||
for (const column of SYNC_TABLES[name].columns) {
|
||||
assert.match(body, new RegExp(`\\b${column.name}\\b`));
|
||||
}
|
||||
const hasPin = SYNC_TABLES[name].columns.some((column) => column.name === "pin_hash");
|
||||
assert.equal(hasPin, name === "staff_members");
|
||||
}
|
||||
|
||||
const customers = sql.match(/CREATE TABLE IF NOT EXISTS customers \(([\s\S]*?)\n\)/);
|
||||
assert.match(customers?.[1] ?? "", /idx_customers_phone \(phone\)/);
|
||||
const invoices = sql.match(/CREATE TABLE IF NOT EXISTS invoices \(([\s\S]*?)\n\)/);
|
||||
assert.match(invoices?.[1] ?? "", /uq_invoices_public_token \(public_token\)/);
|
||||
const jobs = sql.match(/CREATE TABLE IF NOT EXISTS job_cards \(([\s\S]*?)\n\)/);
|
||||
assert.match(jobs?.[1] ?? "", /idx_job_cards_customer \(customer_id\)/);
|
||||
assert.match(jobs?.[1] ?? "", /idx_job_cards_vehicle \(vehicle_id\)/);
|
||||
});
|
||||
|
||||
test("public lookup code does not select private columns", () => {
|
||||
const status = readFileSync("lib/status.ts", "utf8");
|
||||
for (const word of ["engineer_notes", "complaint", "pin_hash", "sale_price", "unit_price", "qty_on_hand"]) {
|
||||
assert.equal(status.includes(word), false, word);
|
||||
}
|
||||
const sync = readFileSync("lib/sync.ts", "utf8");
|
||||
assert.equal(sync.includes("console."), false);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user