This commit is contained in:
2026-09-28 23:15:47 +05:30
commit 605c6a2e58
41 changed files with 4299 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
DATABASE_URL=mysql://hundredkmph:password@127.0.0.1:3306/hundredkmph
SYNC_TOKEN=replace-with-a-long-random-secret
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.env
.env.local
.env.*.local
*.log
+40
View File
@@ -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 });
}
}
+30
View File
@@ -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 });
}
}
+30
View File
@@ -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 });
}
}
+79
View File
@@ -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>
);
}
+109
View File
@@ -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;
}
}
+14
View File
@@ -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>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function HomePage() {
return <main className="home">100kmph</main>;
}
+24
View File
@@ -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: [] };
}
}
+12
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+374
View File
@@ -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;
+58
View File
@@ -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.
+7
View File
@@ -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/
+19
View File
@@ -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
+16
View File
@@ -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
+11
View File
@@ -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);
}
+73
View File
@@ -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" });
}
+58
View File
@@ -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;
}
+24
View File
@@ -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;
}
+5
View File
@@ -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();
}
+48
View File
@@ -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 };
+16
View File
@@ -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);
}
+4
View File
@@ -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 });
}
+7
View File
@@ -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;
}
+26
View File
@@ -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";
}
+329
View File
@@ -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);
+4
View File
@@ -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}\``;
}
+69
View File
@@ -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
View File
@@ -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 };
}
+25
View File
@@ -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();
}
+47
View File
@@ -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[];
};
+6
View File
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["mysql2"],
};
export default nextConfig;
+1634
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+308
View File
@@ -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);
}
}
});
+210
View File
@@ -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);
});
+21
View File
@@ -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"]
}