From 605c6a2e58cc6458b8f61a5bdfef48204ce99131 Mon Sep 17 00:00:00 2001 From: Sewmina Date: Mon, 28 Sep 2026 23:15:47 +0530 Subject: [PATCH] init --- Instructions.md | 214 ++++ web/.env.example | 2 + web/.gitignore | 6 + web/app/api/bills/[token]/route.ts | 40 + web/app/api/sync/pull/route.ts | 30 + web/app/api/sync/push/route.ts | 30 + web/app/b/[token]/page.tsx | 79 ++ web/app/globals.css | 109 ++ web/app/layout.tsx | 14 + web/app/page.tsx | 3 + web/app/status/actions.ts | 24 + web/app/status/page.tsx | 12 + web/app/status/status-form.tsx | 53 + web/db/001_init.sql | 374 +++++++ web/deploy/README.md | 58 + web/deploy/apache-hundredkmph.conf | 7 + web/deploy/hundredkmph-web.service | 19 + web/deploy/mariadb-grants.sql | 16 + web/lib/auth.ts | 11 + web/lib/bill-view.ts | 73 ++ web/lib/bills.ts | 58 + web/lib/body.ts | 24 + web/lib/conflict.ts | 5 + web/lib/db.ts | 48 + web/lib/ids.ts | 16 + web/lib/money.ts | 4 + web/lib/phone.ts | 7 + web/lib/rate-limit.ts | 26 + web/lib/schema.ts | 329 ++++++ web/lib/sql.ts | 4 + web/lib/status.ts | 69 ++ web/lib/sync.ts | 253 +++++ web/lib/time.ts | 25 + web/lib/workshop.ts | 47 + web/next-env.d.ts | 6 + web/next.config.ts | 8 + web/package-lock.json | 1634 ++++++++++++++++++++++++++++ web/package.json | 23 + web/tests/sync.integration.test.ts | 308 ++++++ web/tests/unit.test.ts | 210 ++++ web/tsconfig.json | 21 + 41 files changed, 4299 insertions(+) create mode 100644 Instructions.md create mode 100644 web/.env.example create mode 100644 web/.gitignore create mode 100644 web/app/api/bills/[token]/route.ts create mode 100644 web/app/api/sync/pull/route.ts create mode 100644 web/app/api/sync/push/route.ts create mode 100644 web/app/b/[token]/page.tsx create mode 100644 web/app/globals.css create mode 100644 web/app/layout.tsx create mode 100644 web/app/page.tsx create mode 100644 web/app/status/actions.ts create mode 100644 web/app/status/page.tsx create mode 100644 web/app/status/status-form.tsx create mode 100644 web/db/001_init.sql create mode 100644 web/deploy/README.md create mode 100644 web/deploy/apache-hundredkmph.conf create mode 100644 web/deploy/hundredkmph-web.service create mode 100644 web/deploy/mariadb-grants.sql create mode 100644 web/lib/auth.ts create mode 100644 web/lib/bill-view.ts create mode 100644 web/lib/bills.ts create mode 100644 web/lib/body.ts create mode 100644 web/lib/conflict.ts create mode 100644 web/lib/db.ts create mode 100644 web/lib/ids.ts create mode 100644 web/lib/money.ts create mode 100644 web/lib/phone.ts create mode 100644 web/lib/rate-limit.ts create mode 100644 web/lib/schema.ts create mode 100644 web/lib/sql.ts create mode 100644 web/lib/status.ts create mode 100644 web/lib/sync.ts create mode 100644 web/lib/time.ts create mode 100644 web/lib/workshop.ts create mode 100644 web/next-env.d.ts create mode 100644 web/next.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/tests/sync.integration.test.ts create mode 100644 web/tests/unit.test.ts create mode 100644 web/tsconfig.json diff --git a/Instructions.md b/Instructions.md new file mode 100644 index 0000000..4ddd4dc --- /dev/null +++ b/Instructions.md @@ -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 `. 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. diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..0163a7c --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL=mysql://hundredkmph:password@127.0.0.1:3306/hundredkmph +SYNC_TOKEN=replace-with-a-long-random-secret diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..967128b --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,6 @@ +node_modules +.next +.env +.env.local +.env.*.local +*.log diff --git a/web/app/api/bills/[token]/route.ts b/web/app/api/bills/[token]/route.ts new file mode 100644 index 0000000..60b26ab --- /dev/null +++ b/web/app/api/bills/[token]/route.ts @@ -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 }); + } +} diff --git a/web/app/api/sync/pull/route.ts b/web/app/api/sync/pull/route.ts new file mode 100644 index 0000000..98a4dd6 --- /dev/null +++ b/web/app/api/sync/pull/route.ts @@ -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 }); + } +} diff --git a/web/app/api/sync/push/route.ts b/web/app/api/sync/push/route.ts new file mode 100644 index 0000000..48faa03 --- /dev/null +++ b/web/app/api/sync/push/route.ts @@ -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 }); + } +} diff --git a/web/app/b/[token]/page.tsx b/web/app/b/[token]/page.tsx new file mode 100644 index 0000000..3414ec2 --- /dev/null +++ b/web/app/b/[token]/page.tsx @@ -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 => { + const raw = await readBillPayload(token); + if (!raw) return null; + return parseBill(raw); +}); + +export async function generateMetadata({ + params, +}: { + params: Promise<{ token: string }>; +}): Promise { + 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
{BILL_NOT_FOUND}
; + + const when = formatBillDate(bill.createdAt); + return ( +
+

{bill.shopName || "100kmph"}

+

{bill.address}

+

{bill.phone ? `Ph: ${bill.phone}` : ""}

+
+

+ Invoice {bill.number} +
+ {when} +

+

+ Customer: {bill.customerName || "-"} +
+ Phone: {bill.customerPhone || "-"} + {bill.vehicleReg ? ( + <> +
+ Vehicle: {bill.vehicleReg} {bill.vehicleModel} + + ) : null} +

+ + + + + + + + + + + {bill.lines.map((line, index) => ( + + + + + + + ))} + +
ItemQtyRateAmount
{line.description}{line.qty ?? ""}{money(line.unitPrice)}{money(line.lineTotal)}
+

Total {money(bill.total)}

+

Paid by: {bill.paymentMethod.toUpperCase()}

+

Digital invoice from 100kmph. You can print this page if you need paper.

+
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..48b7faf --- /dev/null +++ b/web/app/globals.css @@ -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; + } +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..dedab95 --- /dev/null +++ b/web/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..0c30c49 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,3 @@ +export default function HomePage() { + return
100kmph
; +} diff --git a/web/app/status/actions.ts b/web/app/status/actions.ts new file mode 100644 index 0000000..e1baa24 --- /dev/null +++ b/web/app/status/actions.ts @@ -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 { + 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: [] }; + } +} diff --git a/web/app/status/page.tsx b/web/app/status/page.tsx new file mode 100644 index 0000000..4cdc717 --- /dev/null +++ b/web/app/status/page.tsx @@ -0,0 +1,12 @@ +import { StatusForm } from "./status-form"; + +export const dynamic = "force-dynamic"; + +export default function StatusPage() { + return ( +
+

100kmph

+ +
+ ); +} diff --git a/web/app/status/status-form.tsx b/web/app/status/status-form.tsx new file mode 100644 index 0000000..87f18fe --- /dev/null +++ b/web/app/status/status-form.tsx @@ -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(lookupStatus, null); + + return ( +
+ + + {state?.error ?

{state.error}

: null} + {state?.message ?

{state.message}

: null} + {state && state.bikes.length > 0 ? ( +
    + {state.bikes.map((bike, index) => { + const when = formatWhen(bike.stageAt); + return ( +
  • +
    {bike.registration}
    +
    + {bike.make} {bike.model} +
    + {bike.inWorkshop ? ( +
    + {bike.stageLabel} + {when ? ` · ${when}` : ""} +
    + ) : ( +
    {NOT_IN_WORKSHOP}
    + )} +
  • + ); + })} +
+ ) : null} +
+ ); +} diff --git a/web/db/001_init.sql b/web/db/001_init.sql new file mode 100644 index 0000000..6ecc974 --- /dev/null +++ b/web/db/001_init.sql @@ -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; diff --git a/web/deploy/README.md b/web/deploy/README.md new file mode 100644 index 0000000..e063365 --- /dev/null +++ b/web/deploy/README.md @@ -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. diff --git a/web/deploy/apache-hundredkmph.conf b/web/deploy/apache-hundredkmph.conf new file mode 100644 index 0000000..3782862 --- /dev/null +++ b/web/deploy/apache-hundredkmph.conf @@ -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/ diff --git a/web/deploy/hundredkmph-web.service b/web/deploy/hundredkmph-web.service new file mode 100644 index 0000000..e2a75ca --- /dev/null +++ b/web/deploy/hundredkmph-web.service @@ -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 diff --git a/web/deploy/mariadb-grants.sql b/web/deploy/mariadb-grants.sql new file mode 100644 index 0000000..7db5817 --- /dev/null +++ b/web/deploy/mariadb-grants.sql @@ -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 diff --git a/web/lib/auth.ts b/web/lib/auth.ts new file mode 100644 index 0000000..e5a214d --- /dev/null +++ b/web/lib/auth.ts @@ -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); +} diff --git a/web/lib/bill-view.ts b/web/lib/bill-view.ts new file mode 100644 index 0000000..80166ae --- /dev/null +++ b/web/lib/bill-view.ts @@ -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 { + 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" }); +} diff --git a/web/lib/bills.ts b/web/lib/bills.ts new file mode 100644 index 0000000..60cbf7b --- /dev/null +++ b/web/lib/bills.ts @@ -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 { + 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( + `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 { + if (!isUuid(token)) return null; + const pool = getPool(); + const [invoices] = await pool.query( + `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( + `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; +} diff --git a/web/lib/body.ts b/web/lib/body.ts new file mode 100644 index 0000000..4861a26 --- /dev/null +++ b/web/lib/body.ts @@ -0,0 +1,24 @@ +export const BILL_BODY_LIMIT = 64 * 1024; + +export async function readLimited(request: Request, max: number): Promise { + 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; +} diff --git a/web/lib/conflict.ts b/web/lib/conflict.ts new file mode 100644 index 0000000..75a94f3 --- /dev/null +++ b/web/lib/conflict.ts @@ -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(); +} diff --git a/web/lib/db.ts b/web/lib/db.ts new file mode 100644 index 0000000..94e600b --- /dev/null +++ b/web/lib/db.ts @@ -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 { + if (!pool) return; + await pool.end(); + pool = undefined; +} + +export async function withTransaction(fn: (conn: PoolConnection) => Promise): Promise { + 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 }; diff --git a/web/lib/ids.ts b/web/lib/ids.ts new file mode 100644 index 0000000..41cd02a --- /dev/null +++ b/web/lib/ids.ts @@ -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); +} diff --git a/web/lib/money.ts b/web/lib/money.ts new file mode 100644 index 0000000..f411bbd --- /dev/null +++ b/web/lib/money.ts @@ -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 }); +} diff --git a/web/lib/phone.ts b/web/lib/phone.ts new file mode 100644 index 0000000..cef750e --- /dev/null +++ b/web/lib/phone.ts @@ -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; +} diff --git a/web/lib/rate-limit.ts b/web/lib/rate-limit.ts new file mode 100644 index 0000000..823dd4c --- /dev/null +++ b/web/lib/rate-limit.ts @@ -0,0 +1,26 @@ +const WINDOW_MS = 60_000; +const LIMIT = 10; +const buckets = new Map(); + +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"; +} diff --git a/web/lib/schema.ts b/web/lib/schema.ts new file mode 100644 index 0000000..b6adcf9 --- /dev/null +++ b/web/lib/schema.ts @@ -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>, +): 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 = { + 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); diff --git a/web/lib/sql.ts b/web/lib/sql.ts new file mode 100644 index 0000000..ab29705 --- /dev/null +++ b/web/lib/sql.ts @@ -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}\``; +} diff --git a/web/lib/status.ts b/web/lib/status.ts new file mode 100644 index 0000000..9e2d970 --- /dev/null +++ b/web/lib/status.ts @@ -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 { + const phone = normalizePhone(rawPhone); + if (!phone) return { message: NO_BIKE, error: null, bikes: [] }; + + const pool = getPool(); + const [customers] = await pool.query( + `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( + `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( + `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( + "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 }; +} diff --git a/web/lib/sync.ts b/web/lib/sync.ts new file mode 100644 index 0000000..ba95ed5 --- /dev/null +++ b/web/lib/sync.ts @@ -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; + rejected: Record; +}; + +export type Cursor = { updatedAt: string; id: string }; + +export type PullResult = { + tables: Record[]>; + nextCursors: Record; +}; + +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; + 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 { + // 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 = {}; + const rejected: Record = {}; + + await withTransaction(async (conn) => { + for (const [name, rows] of Object.entries(tables as Record)) { + 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( + `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; + 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; + const cursors: Record = {}; + 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; + 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 { + const resolved = resolveCursors(cursorsInput); + if (resolved.error) throw new SyncInputError(resolved.error); + + const pool = getPool(); + const tables: Record[]> = {}; + const nextCursors: Record = {}; + + 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( + `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 = {}; + 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 }; +} diff --git a/web/lib/time.ts b/web/lib/time.ts new file mode 100644 index 0000000..c74ec4b --- /dev/null +++ b/web/lib/time.ts @@ -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(); +} diff --git a/web/lib/workshop.ts b/web/lib/workshop.ts new file mode 100644 index 0000000..52f369f --- /dev/null +++ b/web/lib/workshop.ts @@ -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 = { + 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[]; +}; diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000..73427b4 --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + serverExternalPackages: ["mysql2"], +}; + +export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..a95a438 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1634 @@ +{ + "name": "hundredkmph-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hundredkmph-web", + "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" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.26.tgz", + "integrity": "sha512-NJBz9q10LU9h3KjHLEbdgWIV+ow/x+MYzKBRfqhm9/QmML3tPMhYmXF/UIV9SDVCNtOqFNc5oX7kZqeiigMCEA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.26.tgz", + "integrity": "sha512-So8eoJxIcXw/TexNUvvh3uY72J9nDo5BpJsAwUKx+FK57CrWXg6RqVufV7U9OT3BO+siMzJ2FuAwBhaHoPlLGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.26.tgz", + "integrity": "sha512-jImzLUTClVWKhP91e5sgDumjxCLhaFSt7DuN5cnRYw99Dppxxhhq5jKRyDa2aTv3JE7dQmTSTsY3EFkSOp9Pog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.26.tgz", + "integrity": "sha512-CaWd+T/Lud2BmZbrsa1CzCnIOdU3YX9Nuk89virZaSB1O+C+8Yrrevgmnl68u4dvZIfOAzQ77S+Njrq7v1XwSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.26.tgz", + "integrity": "sha512-97AyKI34yjpaudlkWHswAf7c1PjWQAC7lLyrw3R5K+bq834EOA+9IG68rIVy0VqrqGjtPSMjJmMmgeJ3wHR5og==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.26.tgz", + "integrity": "sha512-eVtuOCew1sBPV7BEgxy7qxuVyqoU3tJIS/xDZwa1/NiQ4Q0LM2JMH7rny+/uVIN6hsQ3PTb0hTQWypA0L2QxtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.26.tgz", + "integrity": "sha512-EiUXADp+Z+OdQnSqbX10YgOSUrs0CXVODoTfySfsP2jdhngn9bq5RJd376FJzqMPe/XX25FMr1aXtYUVPA0qDw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.26.tgz", + "integrity": "sha512-HPl41fgkC4kdM5CCIoqNW6KlKEj1N+xS6bjNFFxDNKooUNUu09frgD948zo95TPw/C3XINZpkdkBGNU2RhjEnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.26.tgz", + "integrity": "sha512-TgmJ5ginKr34RPsz01/swpYtFBxh51d66jM26aytpE6NIypU5KtBVI8C2hJjUNpWr7v6sWj8a6+og2MyntcnpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001812", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", + "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mysql2": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", + "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.26", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.26.tgz", + "integrity": "sha512-EVCqhvq8Hs+nX9udH2VzE/iXAg9QodZBZnwVJTuAMl386GIYvlJtYhFytV9nSlDYxKw3kEyv8I2dCQs0+on0sQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.26", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.26", + "@next/swc-darwin-x64": "15.5.26", + "@next/swc-linux-arm64-gnu": "15.5.26", + "@next/swc-linux-arm64-musl": "15.5.26", + "@next/swc-linux-x64-gnu": "15.5.26", + "@next/swc-linux-x64-musl": "15.5.26", + "@next/swc-win32-arm64-msvc": "15.5.26", + "@next/swc-win32-x64-msvc": "15.5.26", + "sharp": "^0.34.3 || ^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..9700fac --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/tests/sync.integration.test.ts b/web/tests/sync.integration.test.ts new file mode 100644 index 0000000..44e04f2 --- /dev/null +++ b/web/tests/sync.integration.test.ts @@ -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 { + 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 { + 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 = {}; + for (const name of SYNC_TABLE_NAMES) cursors[name] = { ...future }; + cursors.customers = cursor; + return cursors; +} + +async function findCustomer(id: string): Promise | 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("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( + "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); + } + } +}); diff --git a/web/tests/unit.test.ts b/web/tests/unit.test.ts new file mode 100644 index 0000000..8a0b692 --- /dev/null +++ b/web/tests/unit.test.ts @@ -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); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..8b1f373 --- /dev/null +++ b/web/tsconfig.json @@ -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"] +}