init
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
|
||||
export function isAuthorized(header: string | null): boolean {
|
||||
const expected = process.env.SYNC_TOKEN;
|
||||
if (!expected || !header) return false;
|
||||
const match = /^Bearer (\S+)$/.exec(header);
|
||||
if (!match) return false;
|
||||
const presented = createHash("sha256").update(match[1]).digest();
|
||||
const actual = createHash("sha256").update(expected).digest();
|
||||
return timingSafeEqual(presented, actual);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export const BILL_NOT_FOUND =
|
||||
"Bill not found yet. If you just received the SMS, wait a moment and refresh.";
|
||||
|
||||
export type BillLine = {
|
||||
description: string;
|
||||
qty: number | string | null;
|
||||
unitPrice: number;
|
||||
lineTotal: number;
|
||||
};
|
||||
|
||||
export type BillView = {
|
||||
shopName: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
number: string;
|
||||
createdAt: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
vehicleReg: string;
|
||||
vehicleModel: string;
|
||||
lines: BillLine[];
|
||||
total: number;
|
||||
paymentMethod: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function parseBill(raw: string): BillView | null {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(data)) return null;
|
||||
const lines = Array.isArray(data.lines) ? data.lines.filter(isRecord) : [];
|
||||
return {
|
||||
shopName: asString(data.shopName),
|
||||
address: asString(data.address),
|
||||
phone: asString(data.phone),
|
||||
number: asString(data.number),
|
||||
createdAt: asString(data.createdAt),
|
||||
customerName: asString(data.customerName),
|
||||
customerPhone: asString(data.customerPhone),
|
||||
vehicleReg: asString(data.vehicleReg),
|
||||
vehicleModel: asString(data.vehicleModel),
|
||||
lines: lines.map((line) => ({
|
||||
description: asString(line.description),
|
||||
qty: typeof line.qty === "number" || typeof line.qty === "string" ? line.qty : null,
|
||||
unitPrice: asNumber(line.unitPrice),
|
||||
lineTotal: asNumber(line.lineTotal),
|
||||
})),
|
||||
total: asNumber(data.total),
|
||||
paymentMethod: asString(data.paymentMethod),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBillDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString("en-LK", { timeZone: "Asia/Colombo" });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { BILL_BODY_LIMIT } from "./body";
|
||||
import { withTransaction, getPool } from "./db";
|
||||
import { isUuid } from "./ids";
|
||||
|
||||
export class BillStoreError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function storePublishedBill(token: string, raw: string): Promise<void> {
|
||||
if (!isUuid(token)) throw new BillStoreError(400, "invalid token");
|
||||
if (Buffer.byteLength(raw, "utf8") > BILL_BODY_LIMIT) throw new BillStoreError(413, "body too large");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new BillStoreError(400, "invalid json");
|
||||
}
|
||||
const isSnapshot = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
await withTransaction(async (conn) => {
|
||||
await conn.query(
|
||||
`INSERT INTO bill_snapshots (token, payload_json, updated_at)
|
||||
VALUES (?, ?, UTC_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE payload_json = VALUES(payload_json), updated_at = UTC_TIMESTAMP(3)`,
|
||||
[token, raw],
|
||||
);
|
||||
if (isSnapshot) {
|
||||
await conn.query<ResultSetHeader>(
|
||||
`UPDATE invoices SET snapshot_json = ? WHERE public_token = ? AND deleted_at IS NULL`,
|
||||
[raw, token],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoice snapshot first, then the legacy store. */
|
||||
export async function readBillPayload(token: string): Promise<string | null> {
|
||||
if (!isUuid(token)) return null;
|
||||
const pool = getPool();
|
||||
const [invoices] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT snapshot_json FROM invoices WHERE public_token = ? AND deleted_at IS NULL LIMIT 1`,
|
||||
[token],
|
||||
);
|
||||
const snapshot = invoices[0]?.snapshot_json;
|
||||
if (typeof snapshot === "string" && snapshot.length > 0) return snapshot;
|
||||
const [legacy] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT payload_json FROM bill_snapshots WHERE token = ? LIMIT 1`,
|
||||
[token],
|
||||
);
|
||||
const payload = legacy[0]?.payload_json;
|
||||
if (typeof payload === "string" && payload.length > 0) return payload;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const BILL_BODY_LIMIT = 64 * 1024;
|
||||
|
||||
export async function readLimited(request: Request, max: number): Promise<string | null> {
|
||||
const declared = request.headers.get("content-length");
|
||||
if (declared && Number(declared) > max) return null;
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) return "";
|
||||
let total = 0;
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > max) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Last write wins. Equal timestamps: incoming wins. No server row: insert. */
|
||||
export function incomingWins(serverUpdatedAt: Date | null, incomingUpdatedAt: Date): boolean {
|
||||
if (serverUpdatedAt === null) return true;
|
||||
return incomingUpdatedAt.getTime() >= serverUpdatedAt.getTime();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import mysql, { type Pool, type PoolConnection, type RowDataPacket } from "mysql2/promise";
|
||||
|
||||
let pool: Pool | undefined;
|
||||
|
||||
export function getPool(): Pool {
|
||||
if (!pool) {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is not set");
|
||||
const url = new URL(databaseUrl);
|
||||
pool = mysql.createPool({
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: decodeURIComponent(url.pathname.replace(/^\//, "")),
|
||||
connectionLimit: 10,
|
||||
dateStrings: true,
|
||||
timezone: "Z",
|
||||
charset: "utf8mb4",
|
||||
supportBigNumbers: true,
|
||||
multipleStatements: false,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
if (!pool) return;
|
||||
await pool.end();
|
||||
pool = undefined;
|
||||
}
|
||||
|
||||
export async function withTransaction<T>(fn: (conn: PoolConnection) => Promise<T>): Promise<T> {
|
||||
const conn = await getPool().getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const result = await fn(conn);
|
||||
await conn.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
export type { RowDataPacket };
|
||||
@@ -0,0 +1,16 @@
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Bill tokens are UUID v4 from the tablet. */
|
||||
export function isUuid(value: string): boolean {
|
||||
return UUID_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row ids are UUID v4, except shop_settings which the tablet seeds as "default".
|
||||
* CHAR(36) and a tight charset. Values are still bound as parameters.
|
||||
*/
|
||||
const ROW_ID_RE = /^[0-9a-zA-Z_-]{1,36}$/;
|
||||
|
||||
export function isRowId(value: string): boolean {
|
||||
return ROW_ID_RE.test(value);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function money(n: number): string {
|
||||
const v = Number(n || 0);
|
||||
return "Rs " + v.toLocaleString("en-LK", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Digits only. Drops a leading 0 or 91 so the same mobile is one customer. */
|
||||
export function normalizePhone(raw: string): string {
|
||||
let d = raw.replace(/\D/g, "");
|
||||
if (d.length === 12 && d.startsWith("91")) d = d.slice(2);
|
||||
if (d.length === 11 && d.startsWith("0")) d = d.slice(1);
|
||||
return d;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const WINDOW_MS = 60_000;
|
||||
const LIMIT = 10;
|
||||
const buckets = new Map<string, number[]>();
|
||||
|
||||
export function resetRateLimit(): void {
|
||||
buckets.clear();
|
||||
}
|
||||
|
||||
/** True when the caller is still inside 10 requests per minute. */
|
||||
export function allowRequest(ip: string, now = Date.now()): boolean {
|
||||
const fresh = (buckets.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
|
||||
if (fresh.length >= LIMIT) {
|
||||
buckets.set(ip, fresh);
|
||||
return false;
|
||||
}
|
||||
fresh.push(now);
|
||||
buckets.set(ip, fresh);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Last hop. Apache appends the real client after any address the browser sent. */
|
||||
export function clientIp(header: string | null): string {
|
||||
if (!header) return "direct";
|
||||
const parts = header.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
return parts[parts.length - 1] || "direct";
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
export type ColumnType = "uuid" | "string" | "longtext" | "bool" | "double" | "int" | "datetime";
|
||||
|
||||
export type Column = {
|
||||
name: string;
|
||||
type: ColumnType;
|
||||
nullable: boolean;
|
||||
hasDefault: boolean;
|
||||
max?: number;
|
||||
};
|
||||
|
||||
export type TableSpec = {
|
||||
name: string;
|
||||
columns: Column[];
|
||||
};
|
||||
|
||||
function col(
|
||||
name: string,
|
||||
type: ColumnType,
|
||||
extra?: Partial<Pick<Column, "nullable" | "hasDefault" | "max">>,
|
||||
): Column {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
nullable: extra?.nullable ?? false,
|
||||
hasDefault: extra?.hasDefault ?? false,
|
||||
max: extra?.max,
|
||||
};
|
||||
}
|
||||
|
||||
function tail(): Column[] {
|
||||
return [
|
||||
col("updated_at", "datetime"),
|
||||
col("deleted_at", "datetime", { nullable: true, hasDefault: true }),
|
||||
col("is_dirty", "bool", { hasDefault: true }),
|
||||
];
|
||||
}
|
||||
|
||||
const id = () => col("id", "uuid");
|
||||
const text = (name: string, max: number, hasDefault = false) =>
|
||||
col(name, "string", { max, hasDefault });
|
||||
const ref = (name: string, nullable = false) => col(name, "uuid", { nullable });
|
||||
|
||||
export const SYNC_TABLES: Record<string, TableSpec> = {
|
||||
staff_members: {
|
||||
name: "staff_members",
|
||||
columns: [
|
||||
id(),
|
||||
text("name", 255),
|
||||
text("pin_hash", 255),
|
||||
text("role", 64),
|
||||
col("active", "bool", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
shop_settings: {
|
||||
name: "shop_settings",
|
||||
columns: [
|
||||
id(),
|
||||
text("name", 255),
|
||||
text("address", 4000, true),
|
||||
text("phone", 64, true),
|
||||
text("gstin", 64, true),
|
||||
col("tax_percent", "double", { hasDefault: true }),
|
||||
text("sms_bill_template", 4000),
|
||||
text("sms_status_template", 4000),
|
||||
// v1 does not allocate invoice numbers. next_invoice_number is last-write-wins.
|
||||
// Two tablets incrementing it will clash.
|
||||
col("next_invoice_number", "int", { hasDefault: true }),
|
||||
text("expected_open", 16, true),
|
||||
text("expected_close", 16, true),
|
||||
col("late_grace_minutes", "int", { hasDefault: true }),
|
||||
text("public_base_url", 512),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
work_days: {
|
||||
name: "work_days",
|
||||
columns: [
|
||||
id(),
|
||||
text("day_key", 32),
|
||||
col("started_at", "datetime"),
|
||||
col("ended_at", "datetime", { nullable: true }),
|
||||
ref("started_by_staff_id"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
attendance_records: {
|
||||
name: "attendance_records",
|
||||
columns: [
|
||||
id(),
|
||||
ref("work_day_id"),
|
||||
ref("staff_id"),
|
||||
col("check_in_at", "datetime", { nullable: true }),
|
||||
col("check_out_at", "datetime", { nullable: true }),
|
||||
text("notes", 4000, true),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
short_leaves: {
|
||||
name: "short_leaves",
|
||||
columns: [
|
||||
id(),
|
||||
ref("work_day_id"),
|
||||
ref("staff_id"),
|
||||
col("from_at", "datetime"),
|
||||
col("to_at", "datetime"),
|
||||
col("minutes", "int"),
|
||||
text("reason", 4000, true),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
overtime_logs: {
|
||||
name: "overtime_logs",
|
||||
columns: [
|
||||
id(),
|
||||
ref("work_day_id"),
|
||||
ref("staff_id"),
|
||||
col("from_at", "datetime", { nullable: true }),
|
||||
col("to_at", "datetime", { nullable: true }),
|
||||
col("minutes", "int"),
|
||||
text("reason", 4000, true),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
customers: {
|
||||
name: "customers",
|
||||
columns: [
|
||||
id(),
|
||||
text("phone", 64),
|
||||
text("name", 255),
|
||||
col("created_at", "datetime", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
vehicles: {
|
||||
name: "vehicles",
|
||||
columns: [
|
||||
id(),
|
||||
ref("customer_id"),
|
||||
text("registration", 64),
|
||||
text("vin", 64, true),
|
||||
text("make", 255, true),
|
||||
text("model_name", 255, true),
|
||||
col("year", "int", { nullable: true }),
|
||||
col("engine_capacity", "int", { nullable: true }),
|
||||
ref("bike_model_id", true),
|
||||
col("created_at", "datetime", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
job_cards: {
|
||||
name: "job_cards",
|
||||
columns: [
|
||||
id(),
|
||||
ref("vehicle_id"),
|
||||
ref("customer_id"),
|
||||
ref("brought_by_customer_id", true),
|
||||
text("complaint", 4000),
|
||||
text("stage", 64),
|
||||
text("engineer_notes", 4000, true),
|
||||
col("created_at", "datetime", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
odo_readings: {
|
||||
name: "odo_readings",
|
||||
columns: [id(), col("read_at", "datetime"), ref("vehicle_id"), col("reading_km", "int"), ...tail()],
|
||||
},
|
||||
job_card_events: {
|
||||
name: "job_card_events",
|
||||
columns: [
|
||||
id(),
|
||||
ref("job_card_id"),
|
||||
text("stage", 64),
|
||||
text("note", 4000, true),
|
||||
col("at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
part_types: {
|
||||
name: "part_types",
|
||||
columns: [id(), text("name", 255), ...tail()],
|
||||
},
|
||||
bike_models: {
|
||||
name: "bike_models",
|
||||
columns: [id(), text("name", 255), text("brand", 255, true), ...tail()],
|
||||
},
|
||||
bike_groups: {
|
||||
name: "bike_groups",
|
||||
columns: [
|
||||
id(),
|
||||
text("name", 255),
|
||||
ref("part_type_id", true),
|
||||
text("code", 64, true),
|
||||
text("notes", 4000, true),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
bike_group_members: {
|
||||
name: "bike_group_members",
|
||||
columns: [id(), ref("group_id"), ref("bike_model_id"), ...tail()],
|
||||
},
|
||||
catalog_parts: {
|
||||
name: "catalog_parts",
|
||||
columns: [
|
||||
id(),
|
||||
ref("part_type_id"),
|
||||
ref("bike_model_id", true),
|
||||
ref("group_id", true),
|
||||
col("is_universal", "bool", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
catalog_part_models: {
|
||||
name: "catalog_part_models",
|
||||
columns: [id(), ref("catalog_part_id"), ref("bike_model_id"), ...tail()],
|
||||
},
|
||||
part_variants: {
|
||||
name: "part_variants",
|
||||
columns: [
|
||||
id(),
|
||||
ref("catalog_part_id"),
|
||||
text("grade", 64),
|
||||
text("brand", 255, true),
|
||||
text("sku", 128, true),
|
||||
col("cost", "double", { hasDefault: true }),
|
||||
col("sale_price", "double"),
|
||||
col("qty_on_hand", "double", { hasDefault: true }),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
job_line_items: {
|
||||
name: "job_line_items",
|
||||
columns: [
|
||||
id(),
|
||||
ref("job_card_id"),
|
||||
text("kind", 64),
|
||||
ref("variant_id", true),
|
||||
text("description", 4000),
|
||||
col("qty", "double"),
|
||||
col("unit_price", "double"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
invoices: {
|
||||
name: "invoices",
|
||||
columns: [
|
||||
id(),
|
||||
text("number", 64),
|
||||
ref("public_token"),
|
||||
ref("job_card_id", true),
|
||||
ref("customer_id", true),
|
||||
text("kind", 64),
|
||||
col("subtotal", "double"),
|
||||
col("tax_percent", "double"),
|
||||
col("tax_amount", "double"),
|
||||
col("total", "double"),
|
||||
col("snapshot_json", "longtext"),
|
||||
text("publish_status", 64),
|
||||
col("created_at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
invoice_lines: {
|
||||
name: "invoice_lines",
|
||||
columns: [
|
||||
id(),
|
||||
ref("invoice_id"),
|
||||
text("description", 4000),
|
||||
col("qty", "double"),
|
||||
col("unit_price", "double"),
|
||||
col("line_total", "double"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
payments: {
|
||||
name: "payments",
|
||||
columns: [
|
||||
id(),
|
||||
ref("invoice_id"),
|
||||
text("method", 64),
|
||||
col("amount", "double"),
|
||||
col("at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
demand_logs: {
|
||||
name: "demand_logs",
|
||||
columns: [
|
||||
id(),
|
||||
ref("part_type_id"),
|
||||
ref("bike_model_id"),
|
||||
ref("variant_id", true),
|
||||
col("qty", "double"),
|
||||
text("reason", 4000, true),
|
||||
col("created_at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
sms_outbox: {
|
||||
name: "sms_outbox",
|
||||
columns: [
|
||||
id(),
|
||||
text("to_phone", 64),
|
||||
text("body", 4000),
|
||||
text("status", 64),
|
||||
text("error", 4000, true),
|
||||
ref("invoice_id", true),
|
||||
ref("job_card_id", true),
|
||||
col("created_at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
publish_outbox: {
|
||||
name: "publish_outbox",
|
||||
columns: [
|
||||
id(),
|
||||
ref("invoice_id"),
|
||||
ref("public_token"),
|
||||
col("payload_json", "longtext"),
|
||||
text("status", 64),
|
||||
text("error", 4000, true),
|
||||
col("created_at", "datetime"),
|
||||
...tail(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SYNC_TABLE_NAMES = Object.keys(SYNC_TABLES);
|
||||
@@ -0,0 +1,4 @@
|
||||
export function quoteIdent(name: string): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(name)) throw new Error("invalid identifier");
|
||||
return `\`${name}\``;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import { getPool } from "./db";
|
||||
import { normalizePhone } from "./phone";
|
||||
import { sqlDateToIso } from "./time";
|
||||
import { NO_BIKE, pickWorkshopJob, stageLabel, type BikeStatus, type StatusResult } from "./workshop";
|
||||
|
||||
export async function lookupBikes(rawPhone: string): Promise<StatusResult> {
|
||||
const phone = normalizePhone(rawPhone);
|
||||
if (!phone) return { message: NO_BIKE, error: null, bikes: [] };
|
||||
|
||||
const pool = getPool();
|
||||
const [customers] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id FROM customers WHERE phone = ? AND deleted_at IS NULL`,
|
||||
[phone],
|
||||
);
|
||||
if (customers.length === 0) return { message: NO_BIKE, error: null, bikes: [] };
|
||||
|
||||
const customerIds = customers.map((row) => String(row.id));
|
||||
const [vehicles] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, registration, make, model_name FROM vehicles
|
||||
WHERE customer_id IN (${customerIds.map(() => "?").join(", ")}) AND deleted_at IS NULL
|
||||
ORDER BY registration ASC, id ASC`,
|
||||
customerIds,
|
||||
);
|
||||
if (vehicles.length === 0) return { message: NO_BIKE, error: null, bikes: [] };
|
||||
|
||||
const bikes: BikeStatus[] = [];
|
||||
for (const vehicle of vehicles) {
|
||||
const [jobs] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, stage, created_at FROM job_cards WHERE vehicle_id = ? AND deleted_at IS NULL`,
|
||||
[vehicle.id],
|
||||
);
|
||||
const picked = pickWorkshopJob(
|
||||
jobs.map((job) => ({
|
||||
id: String(job.id),
|
||||
stage: String(job.stage),
|
||||
createdAt: sqlDateToIso(job.created_at) ?? "",
|
||||
})),
|
||||
);
|
||||
const registration = String(vehicle.registration ?? "");
|
||||
const make = String(vehicle.make ?? "");
|
||||
const model = String(vehicle.model_name ?? "");
|
||||
if (!picked) {
|
||||
bikes.push({
|
||||
registration,
|
||||
make,
|
||||
model,
|
||||
inWorkshop: false,
|
||||
stageLabel: null,
|
||||
stageAt: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const [events] = await pool.query<RowDataPacket[]>(
|
||||
"SELECT `at` FROM job_card_events WHERE job_card_id = ? AND deleted_at IS NULL ORDER BY `at` DESC, id DESC LIMIT 1",
|
||||
[picked.id],
|
||||
);
|
||||
bikes.push({
|
||||
registration,
|
||||
make,
|
||||
model,
|
||||
inWorkshop: true,
|
||||
stageLabel: stageLabel(picked.stage),
|
||||
stageAt: events[0] ? sqlDateToIso(events[0].at) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return { message: null, error: null, bikes };
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import { incomingWins } from "./conflict";
|
||||
import { getPool, withTransaction } from "./db";
|
||||
import { isRowId } from "./ids";
|
||||
import { SYNC_TABLES, SYNC_TABLE_NAMES, type Column, type TableSpec } from "./schema";
|
||||
import { quoteIdent } from "./sql";
|
||||
import { isoToSql, sqlDateToIso } from "./time";
|
||||
|
||||
export class SyncInputError extends Error {}
|
||||
|
||||
export type PushResult = {
|
||||
accepted: Record<string, string[]>;
|
||||
rejected: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type Cursor = { updatedAt: string; id: string };
|
||||
|
||||
export type PullResult = {
|
||||
tables: Record<string, Record<string, unknown>[]>;
|
||||
nextCursors: Record<string, Cursor>;
|
||||
};
|
||||
|
||||
export const EPOCH_ISO = "1970-01-01T00:00:00.000Z";
|
||||
export const PULL_LIMIT = 500;
|
||||
|
||||
type SqlValue = string | number | null;
|
||||
|
||||
export type PreparedRow = {
|
||||
id: string;
|
||||
updatedAt: Date;
|
||||
columns: string[];
|
||||
values: SqlValue[];
|
||||
};
|
||||
|
||||
export function prepareRow(
|
||||
table: TableSpec,
|
||||
raw: unknown,
|
||||
): { ok: true; row: PreparedRow } | { ok: false; reason: "skip" } | { ok: false; reason: "reject"; id: string } {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, reason: "skip" };
|
||||
const record = raw as Record<string, unknown>;
|
||||
if (typeof record.id !== "string" || record.id.length > 36) return { ok: false, reason: "skip" };
|
||||
if (!isRowId(record.id)) return { ok: false, reason: "reject", id: record.id };
|
||||
|
||||
const columns: string[] = [];
|
||||
const values: SqlValue[] = [];
|
||||
for (const column of table.columns) {
|
||||
if (column.name === "id" || column.name === "is_dirty") continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(record, column.name)) {
|
||||
if (column.nullable) {
|
||||
columns.push(column.name);
|
||||
values.push(null);
|
||||
continue;
|
||||
}
|
||||
if (column.hasDefault) continue;
|
||||
return { ok: false, reason: "reject", id: record.id };
|
||||
}
|
||||
const coerced = coerce(column, record[column.name]);
|
||||
if (!coerced.ok) return { ok: false, reason: "reject", id: record.id };
|
||||
columns.push(column.name);
|
||||
values.push(coerced.value);
|
||||
}
|
||||
|
||||
const updatedIndex = columns.indexOf("updated_at");
|
||||
if (updatedIndex < 0 || typeof values[updatedIndex] !== "string") {
|
||||
return { ok: false, reason: "reject", id: record.id };
|
||||
}
|
||||
const updatedAt = new Date(`${(values[updatedIndex] as string).replace(" ", "T")}Z`);
|
||||
if (Number.isNaN(updatedAt.getTime())) return { ok: false, reason: "reject", id: record.id };
|
||||
return { ok: true, row: { id: record.id, updatedAt, columns, values } };
|
||||
}
|
||||
|
||||
function coerce(column: Column, value: unknown): { ok: true; value: SqlValue } | { ok: false } {
|
||||
if (value === null) {
|
||||
if (column.nullable) return { ok: true, value: null };
|
||||
return { ok: false };
|
||||
}
|
||||
switch (column.type) {
|
||||
case "uuid": {
|
||||
if (typeof value !== "string" || !isRowId(value)) return { ok: false };
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "string":
|
||||
case "longtext": {
|
||||
let text: string;
|
||||
if (typeof value === "string") text = value;
|
||||
else if (column.type === "longtext" && value && typeof value === "object") text = JSON.stringify(value);
|
||||
else return { ok: false };
|
||||
if (column.max !== undefined && text.length > column.max) return { ok: false };
|
||||
return { ok: true, value: text };
|
||||
}
|
||||
case "bool": {
|
||||
if (value === true || value === 1) return { ok: true, value: 1 };
|
||||
if (value === false || value === 0) return { ok: true, value: 0 };
|
||||
return { ok: false };
|
||||
}
|
||||
case "double": {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return { ok: false };
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "int": {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) return { ok: false };
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "datetime": {
|
||||
if (typeof value !== "string") return { ok: false };
|
||||
const sql = isoToSql(value);
|
||||
if (!sql) return { ok: false };
|
||||
return { ok: true, value: sql };
|
||||
}
|
||||
default:
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushChanges(tables: unknown): Promise<PushResult> {
|
||||
// v1 does not allocate invoice numbers. shop_settings.next_invoice_number is
|
||||
// last-write-wins like every other column. Two tablets incrementing it will clash.
|
||||
if (!tables || typeof tables !== "object" || Array.isArray(tables)) {
|
||||
throw new SyncInputError("invalid body");
|
||||
}
|
||||
const accepted: Record<string, string[]> = {};
|
||||
const rejected: Record<string, string[]> = {};
|
||||
|
||||
await withTransaction(async (conn) => {
|
||||
for (const [name, rows] of Object.entries(tables as Record<string, unknown>)) {
|
||||
const table = SYNC_TABLES[name];
|
||||
if (!table || !Array.isArray(rows)) continue;
|
||||
accepted[name] = [];
|
||||
rejected[name] = [];
|
||||
for (const raw of rows) {
|
||||
const prepared = prepareRow(table, raw);
|
||||
if (!prepared.ok) {
|
||||
if (prepared.reason === "reject") rejected[name].push(prepared.id);
|
||||
continue;
|
||||
}
|
||||
const [found] = await conn.query<RowDataPacket[]>(
|
||||
`SELECT updated_at FROM ${quoteIdent(table.name)} WHERE id = ? FOR UPDATE`,
|
||||
[prepared.row.id],
|
||||
);
|
||||
const existing = found[0];
|
||||
if (existing) {
|
||||
const serverIso = sqlDateToIso(existing.updated_at);
|
||||
const serverDate = serverIso ? new Date(serverIso) : null;
|
||||
if (serverDate && !incomingWins(serverDate, prepared.row.updatedAt)) {
|
||||
rejected[name].push(prepared.row.id);
|
||||
continue;
|
||||
}
|
||||
const sets = prepared.row.columns.map((column) => `${quoteIdent(column)} = ?`);
|
||||
sets.push(`${quoteIdent("is_dirty")} = 0`);
|
||||
await conn.query(
|
||||
`UPDATE ${quoteIdent(table.name)} SET ${sets.join(", ")} WHERE id = ?`,
|
||||
[...prepared.row.values, prepared.row.id],
|
||||
);
|
||||
} else {
|
||||
const insertCols = ["id", ...prepared.row.columns, "is_dirty"];
|
||||
const insertVals: SqlValue[] = [prepared.row.id, ...prepared.row.values, 0];
|
||||
await conn.query(
|
||||
`INSERT INTO ${quoteIdent(table.name)} (${insertCols.map((column) => quoteIdent(column)).join(", ")}) VALUES (${insertCols.map(() => "?").join(", ")})`,
|
||||
insertVals,
|
||||
);
|
||||
}
|
||||
accepted[name].push(prepared.row.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
export function resolveCursors(input: unknown): {
|
||||
cursors: Record<string, { updatedAt: Date; id: string }>;
|
||||
error?: string;
|
||||
} {
|
||||
const source = input === undefined ? {} : input;
|
||||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||
return { cursors: {}, error: "invalid cursors" };
|
||||
}
|
||||
const raw = source as Record<string, unknown>;
|
||||
const cursors: Record<string, { updatedAt: Date; id: string }> = {};
|
||||
for (const name of SYNC_TABLE_NAMES) {
|
||||
if (!Object.prototype.hasOwnProperty.call(raw, name)) {
|
||||
cursors[name] = { updatedAt: new Date(EPOCH_ISO), id: "" };
|
||||
continue;
|
||||
}
|
||||
const parsed = parseCursor(raw[name]);
|
||||
if (!parsed) return { cursors: {}, error: "invalid cursor" };
|
||||
cursors[name] = parsed;
|
||||
}
|
||||
return { cursors };
|
||||
}
|
||||
|
||||
function parseCursor(value: unknown): { updatedAt: Date; id: string } | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.updatedAt !== "string") return null;
|
||||
const updatedAt = new Date(record.updatedAt);
|
||||
if (Number.isNaN(updatedAt.getTime())) return null;
|
||||
const id = record.id === undefined ? "" : record.id;
|
||||
if (typeof id !== "string") return null;
|
||||
return { updatedAt, id };
|
||||
}
|
||||
|
||||
function serializeValue(column: Column, value: unknown): unknown {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (column.type === "datetime") return sqlDateToIso(value);
|
||||
if (column.type === "bool") {
|
||||
if (column.name === "is_dirty") return 0;
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
if (column.type === "double" || column.type === "int") {
|
||||
return typeof value === "number" ? value : Number(value);
|
||||
}
|
||||
if (Buffer.isBuffer(value)) return value.toString("utf8");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export async function pullChanges(cursorsInput: unknown): Promise<PullResult> {
|
||||
const resolved = resolveCursors(cursorsInput);
|
||||
if (resolved.error) throw new SyncInputError(resolved.error);
|
||||
|
||||
const pool = getPool();
|
||||
const tables: Record<string, Record<string, unknown>[]> = {};
|
||||
const nextCursors: Record<string, Cursor> = {};
|
||||
|
||||
for (const name of SYNC_TABLE_NAMES) {
|
||||
const table = SYNC_TABLES[name];
|
||||
const cursor = resolved.cursors[name];
|
||||
const updatedSql = isoToSql(cursor.updatedAt.toISOString());
|
||||
if (!updatedSql) throw new SyncInputError("invalid cursor");
|
||||
const selectList = table.columns.map((column) => quoteIdent(column.name)).join(", ");
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT ${selectList} FROM ${quoteIdent(name)}
|
||||
WHERE updated_at > ? OR (updated_at = ? AND id > ?)
|
||||
ORDER BY updated_at ASC, id ASC
|
||||
LIMIT ${PULL_LIMIT}`,
|
||||
[updatedSql, updatedSql, cursor.id],
|
||||
);
|
||||
const serialized = rows.map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const column of table.columns) out[column.name] = serializeValue(column, row[column.name]);
|
||||
return out;
|
||||
});
|
||||
tables[name] = serialized;
|
||||
const last = serialized[serialized.length - 1];
|
||||
if (last && typeof last.updated_at === "string" && typeof last.id === "string") {
|
||||
nextCursors[name] = { updatedAt: last.updated_at, id: last.id };
|
||||
} else {
|
||||
nextCursors[name] = { updatedAt: cursor.updatedAt.toISOString(), id: cursor.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { tables, nextCursors };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function isoToSql(iso: string): string | null {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString().slice(0, 23).replace("T", " ");
|
||||
}
|
||||
|
||||
export function sqlDateToIso(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return null;
|
||||
return value.toISOString();
|
||||
}
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
if (/[zZ]$/.test(raw) || /[+-]\d{2}:?\d{2}$/.test(raw)) {
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
const normalized = raw.includes("T") ? raw : raw.replace(" ", "T");
|
||||
const withMs = /\.\d{1,3}$/.test(normalized) ? normalized : `${normalized}.000`;
|
||||
const date = new Date(`${withMs}Z`);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const NO_BIKE = "No bike found for this number.";
|
||||
export const NOT_IN_WORKSHOP = "This bike is not in the workshop.";
|
||||
|
||||
export const STAGE_LABELS: Record<string, string> = {
|
||||
received: "Received",
|
||||
diagnostic: "Diagnostic",
|
||||
in_repair: "In Repair",
|
||||
quality_check: "Quality Check",
|
||||
ready: "Ready",
|
||||
billed: "Billed",
|
||||
};
|
||||
|
||||
export function stageLabel(stage: string): string {
|
||||
return STAGE_LABELS[stage] ?? stage;
|
||||
}
|
||||
|
||||
export type JobPick = {
|
||||
id: string;
|
||||
stage: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** Newest non-deleted job that is not billed. Caller already dropped deleted rows. */
|
||||
export function pickWorkshopJob(jobs: JobPick[]): JobPick | null {
|
||||
const open = jobs.filter((job) => job.stage !== "billed");
|
||||
open.sort((a, b) => {
|
||||
const byTime = b.createdAt.localeCompare(a.createdAt);
|
||||
if (byTime !== 0) return byTime;
|
||||
return b.id.localeCompare(a.id);
|
||||
});
|
||||
return open[0] ?? null;
|
||||
}
|
||||
|
||||
export type BikeStatus = {
|
||||
registration: string;
|
||||
make: string;
|
||||
model: string;
|
||||
inWorkshop: boolean;
|
||||
stageLabel: string | null;
|
||||
stageAt: string | null;
|
||||
};
|
||||
|
||||
export type StatusResult = {
|
||||
message: string | null;
|
||||
error: string | null;
|
||||
bikes: BikeStatus[];
|
||||
};
|
||||
Reference in New Issue
Block a user