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