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