feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
+1
View File
@@ -0,0 +1 @@
schema/
+14 -4
View File
@@ -1,15 +1,25 @@
{
"name": "@kooltap/shared",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"files": [
"dist",
"src"
],
"main": "dist/index.js",
"types": "src/index.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"lint": "echo 'no linter configured'",
"schema": "tsx scripts/json-schema.ts",
"clean": "rimraf dist"
},
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"typescript": "^5.4.0"
"rimraf": "^6.0.1",
"tsx": "^4.20.6",
"typescript": "~5.9.3"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
/** Writes the terminal protocol as JSON Schema, for generating the app's models. */
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { z } from 'zod';
import {
imageUploadResponseSchema, registerTerminalRequestSchema, registerTerminalResponseSchema, syncRequestSchema,
syncResponseSchema, terminalEventSchema,
} from '../src/terminal';
const schemas = {
TerminalEvent: [terminalEventSchema, 'input'],
RegisterTerminalRequest: [registerTerminalRequestSchema, 'input'],
RegisterTerminalResponse: [registerTerminalResponseSchema, 'output'],
SyncRequest: [syncRequestSchema, 'input'],
SyncResponse: [syncResponseSchema, 'output'],
ImageUploadResponse: [imageUploadResponseSchema, 'output'],
} as const;
const out = join(__dirname, '..', 'schema');
mkdirSync(out, { recursive: true });
for (const [name, [schema, io]] of Object.entries(schemas)) {
writeFileSync(join(out, `${name}.json`), JSON.stringify(z.toJSONSchema(schema, { io }), null, 2) + '\n');
}
console.log(`Wrote ${Object.keys(schemas).length} schemas to ${out}`);
+41
View File
@@ -0,0 +1,41 @@
/** A terminal that synced this recently is online. */
export const DEVICE_ONLINE_THRESHOLD_MS = 60_000;
/** A terminal that hasn't synced for this long is offline; in between it was recently seen. */
export const DEVICE_OFFLINE_THRESHOLD_MS = 600_000;
/** How long a terminal's token is valid; it is paired again after that. */
export const DEVICE_TOKEN_DAYS = 90;
/** How long a pairing code can be used. */
export const REGISTRATION_CODE_MINUTES = 10;
/** The header terminals send their app version in; outdated apps get 426. */
export const APP_VERSION_HEADER = 'x-app-version';
/**
* admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders); superadmin: runs
* KoolTap itself, above the venues: the admin page, and any venue they view, as its admin
*/
export const ROLES = ['admin', 'manager', 'superadmin'] as const;
export type Role = (typeof ROLES)[number];
/** The roles a venue's admin can give. */
export const VENUE_ROLES = ['admin', 'manager'] as const satisfies readonly Role[];
/** How a tab was paid. A terminal can also record `other`; the dashboard only offers cash and Payconiq. */
export const PAYMENT_METHODS = ['cash', 'payconiq', 'other'] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
export const DASHBOARD_PAYMENT_METHODS = ['cash', 'payconiq'] as const satisfies readonly PaymentMethod[];
/** add: the stock changes by quantity (a delivery, or breakage when negative); count: the stock is now quantity. */
export const STOCK_MOVEMENT_TYPES = ['add', 'count'] as const;
export type StockMovementType = (typeof STOCK_MOVEMENT_TYPES)[number];
/** What changed; the dashboard refetches the matching data over the API. */
export const REALTIME_TOPICS = ['tabs', 'customers', 'devices', 'reminders', 'users', 'stock'] as const;
export type RealtimeTopic = (typeof REALTIME_TOPICS)[number];
/** What a superadmin can wipe from a venue; users, terminals and products always stay. */
export const RESET_SCOPES = ['tabs', 'settlements', 'tabEvents', 'customers', 'stock', 'stockHistory', 'auditLog'] as const;
export type ResetScope = (typeof RESET_SCOPES)[number];
/** Old history a superadmin can purge. */
export const PURGE_TARGETS = ['tabEvents', 'auditLog'] as const;
export type PurgeTarget = (typeof PURGE_TARGETS)[number];
-6
View File
@@ -1,6 +0,0 @@
export const DEVICE_ONLINE_THRESHOLD_MS = 60_000; // 1 minute
export const DEVICE_OFFLINE_THRESHOLD_MS = 600_000; // 10 minutes
export const COMMAND_EXPIRY_HOURS = 24;
export const TOKEN_EXPIRY_DAYS = 90;
export const TOKEN_PREFIX_LENGTH = 8;
export const TOKEN_BYTE_LENGTH = 48;
+388
View File
@@ -0,0 +1,388 @@
import { z } from 'zod';
import {
DASHBOARD_PAYMENT_METHODS, PAYMENT_METHODS, PaymentMethod, PURGE_TARGETS, RESET_SCOPES, ROLES, Role, STOCK_MOVEMENT_TYPES, StockMovementType,
VENUE_ROLES,
} from './constants';
/**
* The dashboard API: request bodies as zod schemas (the API validates with them) and responses as types (the API's
* services are typed with them, so the dashboard and the API can't drift apart). Dates are ISO strings on the wire.
*/
const email = z.string().trim().toLowerCase().max(254).pipe(z.email());
const password = z.string().min(8).max(200);
const personName = z.string().trim().min(1).max(100);
const cents = z.int().min(0).max(1_000_000);
const amount = z.int().min(0).max(100_000);
const optionalAmount = amount.nullable().optional();
/** An empty category is no category. */
const category = z.string().trim().max(100).nullable().transform((value) => value || null);
const optionalEmail = z.string().trim().toLowerCase().max(254).transform((value) => value || null)
.pipe(z.email().nullable());
export const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
// ---- Auth
export const loginSchema = z.object({ email, password: z.string().min(1).max(200) });
export const changePasswordSchema = z.object({ currentPassword: z.string().min(1).max(200), newPassword: password });
/** The signed-in user. A superadmin belongs to no venue. */
export interface Me {
id: string;
email: string;
name: string;
role: Role;
venueId: string | null;
venueName: string | null;
}
// ---- Venue
export const updateVenueSchema = z.object({
/** Who gets an email for every tab paid in cash, on a terminal or on the dashboard. */
cashPaymentEmails: z.array(email).max(20),
});
export interface Venue {
id: string;
name: string;
cashPaymentEmails: string[];
}
export const createUserSchema = z.object({ email, password, name: personName, role: z.enum(VENUE_ROLES) });
export interface User {
id: string;
email: string;
name: string;
role: Role;
createdAt: string;
/** When the user last signed in or used the dashboard, to within the access token's 15 minutes. */
lastActiveAt: string | null;
}
export interface Device {
id: string;
name: string;
lastSeenAt: string;
createdAt: string;
}
export interface RegistrationCode {
code: string;
expiresAt: string;
}
// ---- Tabs and settlements
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
/** Drinks ordered together on a terminal, shown under the time the order started. */
export interface TabOrder {
orderId: string | null;
startedAt: string;
/** `removed`: drinks taken off again, only in the history */
items: (TabLine & { removed?: number })[];
}
/** An open tab: every terminal of the venue shows the same. */
export interface Tab {
id: string;
customerName: string;
version: number;
totalInCents: number;
itemCount: number;
items: TabLine[];
orders: TabOrder[];
updatedAt: string;
/** The customer with this name, for their email address */
customerId: string | null;
email: string | null;
}
export const settleTabSchema = z.object({
/** Refused when the tab changed since it was shown. */
expectedVersion: z.int().min(0),
paymentMethod: z.enum(DASHBOARD_PAYMENT_METHODS),
});
export const deleteTabQuerySchema = z.object({ expectedVersion: z.coerce.number().int().min(0) });
export const settlementsQuerySchema = paginationSchema.extend({
search: z.string().trim().max(200).optional(),
paymentMethod: z.enum(PAYMENT_METHODS).optional(),
});
/** A paid tab, settled on a terminal or on the dashboard. */
export interface Settlement {
id: string;
tabId: string;
customerName: string;
settledAt: string;
paymentMethod: PaymentMethod;
amountInCents: number;
itemCount: number;
items: (TabLine & { removed: number })[];
orders: TabOrder[];
/** The terminal's name or the dashboard user's; null when it no longer exists */
settledBy: string | null;
onDashboard: boolean;
}
export interface SettlementPage {
settlements: Settlement[];
total: number;
totalAmountInCents: number;
}
// ---- Customers
export const customerSchema = z.object({ name: personName, email: optionalEmail.optional() });
export const importCustomersSchema = z.object({
entries: z.array(z.object({ name: personName, email: optionalEmail.optional() })).max(5000),
/** merge adds new names and fills in emails; replace also removes the names that are not in the import */
mode: z.enum(['merge', 'replace']),
});
/** A regular of the venue: the next sync opens a tab for them when they have none. */
export interface Customer {
id: string;
name: string;
email: string | null;
position: number;
createdAt: string;
/** When their tab was opened; null until the next sync opens it */
tabOpenedAt: string | null;
}
export interface ImportResult {
created: number;
updated: number;
unchanged: number;
removed: number;
}
// ---- Reminders
export const sendRemindersSchema = z.object({
/** Tabs to remind, also ones still in their cooldown; every eligible customer when left out. */
tabIds: z.array(z.uuid()).max(1000).optional(),
});
export interface ReminderCandidate {
tabId: string;
customerName: string;
/** Null when the customer has no email address on file */
email: string | null;
amountInCents: number;
itemCount: number;
items: TabLine[];
reminderCount: number;
lastReminderAt: string | null;
nextEligibleAt: string | null;
/** Has an email and is past the cooldown */
eligible: boolean;
}
export interface Reminder {
id: string;
tabId: string;
customerName: string;
email: string;
amountInCents: number;
sentAt: string;
}
export interface SendRemindersResult {
sent: number;
total: number;
errors?: { customerName: string; error: string }[];
}
// ---- Stock
const productFields = {
name: z.string().trim().min(1).max(200),
category,
/** The sell price, charged on the terminals */
priceInCents: cents,
/** What the bar pays for one; never sent to the terminals */
buyPriceInCents: cents.nullable(),
/** Warn when the stock drops to this level */
lowStockThreshold: amount.nullable(),
/** How many the bar wants in stock; never sent to the terminals */
desiredStock: amount.nullable(),
};
export const createProductSchema = z.object({
name: productFields.name,
category: productFields.category.optional(),
priceInCents: productFields.priceInCents,
buyPriceInCents: optionalAmount,
lowStockThreshold: optionalAmount,
desiredStock: optionalAmount,
/** The starting count; null or left out: stock is not tracked */
stock: optionalAmount,
});
/** Only the fields that are sent change; archived: true deletes (hides) the product. */
export const updateProductSchema = z.object({ ...productFields, archived: z.boolean() }).partial();
export const stockMovementsSchema = z.object({
movements: z.array(z.object({
productId: z.uuid(),
type: z.enum(STOCK_MOVEMENT_TYPES),
/** add: non-zero, negative takes stock out; count: zero or more */
quantity: z.int().min(-100_000).max(100_000),
note: z.string().trim().max(200).transform((value) => value || null).optional(),
})).min(1).max(500),
});
export const stockMovementsQuerySchema = paginationSchema.extend({ productId: z.uuid().optional() });
export interface Product {
id: string;
name: string;
category: string | null;
priceInCents: number;
buyPriceInCents: number | null;
/** Null when stock is not tracked; can be negative */
stock: number | null;
lowStockThreshold: number | null;
desiredStock: number | null;
/** The latest count, on the dashboard or a terminal */
countedAt: string | null;
updatedAt: string;
/** Deleted: hidden on the terminals */
archivedAt: string | null;
/** The picture is at /images/<imageId> */
imageId: string | null;
}
export interface StockMovement {
id: string;
productId: string;
productName: string;
type: StockMovementType;
quantity: number;
note: string | null;
stockAfter: number;
/** Null when the user no longer exists */
userName: string | null;
createdAt: string;
}
// ---- Superadmin
export const venueNameSchema = z.object({ name: z.string().trim().min(1).max(100) });
export const dryRunSchema = z.object({ dryRun: z.boolean().default(false) });
export const deleteVenueQuerySchema = z.object({ confirmName: z.string() });
export const copyCatalogSchema = z.object({ fromVenueId: z.uuid() });
export const resetVenueSchema = dryRunSchema.extend({
scopes: z.array(z.enum(RESET_SCOPES)).min(1),
/** The count every tracked product gets with the stock scope */
stockValue: amount.default(0),
});
export const purgeSchema = dryRunSchema.extend({
targets: z.array(z.enum(PURGE_TARGETS)).min(1),
olderThanDays: z.int().min(1).max(3650),
/** Only this venue; every venue when left out */
venueId: z.uuid().optional(),
});
/** [venueId]: the venue a superadmin joins when they become an admin or manager. */
export const updateRoleSchema = z.object({ role: z.enum(ROLES), venueId: z.uuid().optional() });
export const resetPasswordSchema = z.object({ password });
export const testEmailSchema = z.object({ to: email });
export const auditQuerySchema = paginationSchema.extend({
venueId: z.uuid().optional(),
action: z.string().trim().max(100).optional(),
});
export interface AdminVenue {
id: string;
name: string;
createdAt: string;
users: number;
devices: number;
openTabs: number;
products: number;
customers: number;
settlements: number;
/** The latest sync of any of its terminals */
lastSyncAt: string | null;
}
export interface AdminDevice {
id: string;
name: string;
venueId: string;
venueName: string;
tokenPrefix: string;
tokenExpiresAt: string;
lastSeenAt: string;
revokedAt: string | null;
createdAt: string;
}
export interface AdminUser {
id: string;
email: string;
name: string;
role: Role;
/** Null for a superadmin */
venueId: string | null;
venueName: string | null;
createdAt: string;
activeSessions: number;
}
export interface AdminRegistrationCode {
id: string;
code: string;
venueId: string;
venueName: string;
expiresAt: string;
createdAt: string;
}
export interface AuditEntry {
id: string;
/** Null for actions that are not about one venue */
venueId: string | null;
venueName: string | null;
actorType: 'user' | 'device' | 'system';
actorId: string | null;
/** The user's email or the terminal's name; null when it no longer exists */
actorName: string | null;
action: string;
details: unknown;
createdAt: string;
}
export type FindingSeverity = 'error' | 'warning' | 'info';
export interface Finding {
severity: FindingSeverity;
code: string;
message: string;
/** What it is about: a settlement, tab, product or terminal id */
subjectId: string;
}
export interface SystemHealth {
checkedAt: string;
uptimeSeconds: number;
nodeVersion: string;
databaseBytes: number;
imageBytes: number;
tables: { table: string; rows: number }[];
migrations: { applied: number; latest: string | null; failed: string[] };
realtimeConnections: number;
emailConfigured: boolean;
}
+3 -2
View File
@@ -1,2 +1,3 @@
export * from './types';
export * from './constants';
export * from './constants';
export * from './terminal';
export * from './dashboard';
+221
View File
@@ -0,0 +1,221 @@
import { z } from 'zod';
import { PAYMENT_METHODS } from './constants';
/**
* The terminal protocol: what the bar terminals (the Flutter app) send and get back. The server holds the venue's
* tabs, stock and catalog; a terminal sends what happened on it as events and replaces its own state with what the
* sync returns, replaying the events it has not sent yet on top.
*
* `pnpm --filter @kooltap/shared run schema` writes these schemas as JSON Schema for the app's code generation.
*/
const id = z.uuid();
/** A terminal's own id for an order: unique within its tab. */
const localId = z.string().min(1).max(100);
const isoDate = z.iso.datetime({ offset: true });
const name = z.string().trim().min(1).max(200);
const cents = z.int().min(0).max(100_000_000);
const count = z.int().min(0).max(100_000);
/** An empty category is no category. */
const category = z.string().trim().max(100).nullable().transform((value) => value || null);
const eventBase = {
/** Generated by the terminal; sending the same id again stores and applies the event once. */
id,
/** When it happened on the terminal. A clock running ahead is capped at the server's time. */
occurredAt: isoDate,
};
const productFields = z.object({
name,
category,
priceInCents: cents,
lowStockThreshold: count.nullable(),
/** A picture uploaded to /terminal/images first; null removes it. */
imageId: z.string().max(64).nullable(),
});
export const terminalEventSchema = z.discriminatedUnion('type', [
z.object({
...eventBase,
type: z.literal('tab_opened'),
tabId: id,
customerName: name,
/** Adds the customer with this email, or fills in the email of the customer with this name. */
email: z.string().trim().toLowerCase().max(254).pipe(z.email()).optional(),
}),
z.object({ ...eventBase, type: z.literal('tab_renamed'), tabId: id, customerName: name }),
z.object({ ...eventBase, type: z.literal('tab_deleted'), tabId: id }),
z.object({
...eventBase,
type: z.literal('line_added'),
tabId: id,
/** Opens the tab again when it was deleted elsewhere meanwhile, so no sale is lost. */
customerName: name,
lineId: id,
/** The order the drinks are part of: drinks ordered together are shown together. */
orderId: localId,
productId: id,
productName: name,
unitPriceInCents: cents,
quantity: z.int().min(1).max(1000),
}),
z.object({
...eventBase,
type: z.literal('line_removed'),
tabId: id,
lineId: id,
/** Takes off the latest drinks of this order on the line. */
orderId: localId,
quantity: z.int().min(1).max(1000),
}),
z.object({
...eventBase,
type: z.literal('settled'),
tabId: id,
customerName: name,
amountInCents: cents,
paymentMethod: z.enum(PAYMENT_METHODS),
/** The lines that were settled; the oldest drinks of each line are paid. */
items: z.array(z.object({
lineId: id,
productId: id.nullable(),
productName: name,
unitPriceInCents: cents,
quantity: z.int().min(0).max(100_000),
})).max(500),
}),
/** A stock count; null stops tracking the product's stock. */
z.object({ ...eventBase, type: z.literal('stock_set'), productId: id, quantity: count.nullable() }),
/** A product added on the terminal, also offline: its id is the terminal's, so it can be sold right away. */
z.object({
...eventBase,
type: z.literal('product_created'),
productId: id,
product: productFields.partial().required({ name: true, priceInCents: true }),
}),
/** Only the fields that changed. Per field, the latest change wins. */
z.object({ ...eventBase, type: z.literal('product_updated'), productId: id, product: productFields.partial() }),
z.object({ ...eventBase, type: z.literal('product_archived'), productId: id }),
z.object({ ...eventBase, type: z.literal('product_restored'), productId: id }),
]);
export type TerminalEvent = z.output<typeof terminalEventSchema>;
export type TerminalEventType = TerminalEvent['type'];
export type ProductEvent = Extract<TerminalEvent, { type: `product_${string}` }>;
export type TabEvent = Exclude<TerminalEvent, ProductEvent | { type: 'stock_set' }>;
export const isProductEvent = (event: TerminalEvent): event is ProductEvent => event.type.startsWith('product_');
// ---- Pairing
export const registerTerminalRequestSchema = z.object({
/** The code shown on the dashboard; it says which venue the terminal is for. */
registrationCode: z.string().trim().toUpperCase().min(1).max(20),
name: z.string().trim().min(1).max(100),
});
export type RegisterTerminalRequest = z.output<typeof registerTerminalRequestSchema>;
export const registerTerminalResponseSchema = z.object({
deviceId: id,
venueId: id,
venueName: z.string(),
/** Sent as `Authorization: Bearer <token>` from now on; shown only once. */
token: z.string(),
tokenExpiresAt: isoDate,
});
export type RegisterTerminalResponse = z.output<typeof registerTerminalResponseSchema>;
// ---- Sync
export const syncRequestSchema = z.object({
/** Where the settlements feed left off; absent on a new terminal, which gets the last 30 days. */
settlementsCursor: z.string().max(100).optional(),
/** The catalogVersion of the catalog the terminal has; the catalog is only sent when it changed. */
catalogVersion: z.string().max(100).optional(),
/**
* What happened since the last acknowledged sync, oldest first. Validated one by one: an invalid event is rejected
* on its own instead of failing the sync.
*/
events: z.array(z.unknown()).max(1000).default([]),
});
export type SyncRequest = z.output<typeof syncRequestSchema>;
export const unitSchema = z.object({
/** `<line_added event id>:<index>`: the terminal derives the same id when it replays the event. */
id: z.string(),
addedAt: isoDate,
/** Taken off the tab again. */
removedAt: isoDate.nullable(),
/** Null only for drinks settled on a terminal that the server did not have. */
orderId: z.string().nullable(),
});
export const lineSchema = z.object({
id: z.string(),
productId: id.nullable(),
productName: z.string(),
unitPriceInCents: z.int(),
/** The drinks still on it (or, in a settlement, paid). */
quantity: z.int(),
units: z.array(unitSchema),
});
export const terminalTabSchema = z.object({
id,
customerName: z.string(),
/** The email of the customer with this name; null when there is none. */
email: z.string().nullable(),
openedAt: isoDate,
/** Bumped by every change. */
version: z.int(),
lines: z.array(lineSchema),
});
export const terminalProductSchema = z.object({
id,
name: z.string(),
category: z.string().nullable(),
priceInCents: z.int(),
lowStockThreshold: z.int().nullable(),
/** Deleted on the dashboard: hidden, but kept because tabs and history refer to it. */
archived: z.boolean(),
/** The picture is at /images/<imageId>; null: none. */
imageId: z.string().nullable(),
});
export const terminalSettlementSchema = z.object({
id,
tabId: id,
customerName: z.string(),
settledAt: isoDate,
paymentMethod: z.enum(PAYMENT_METHODS),
amountInCents: z.int(),
lines: z.array(lineSchema),
});
export const syncResponseSchema = z.object({
/** Applied now or before: drop these from the outbox. */
acknowledgedEventIds: z.array(z.string()),
/** Invalid and never applied: log and drop these too, sending them again won't help. */
rejectedEvents: z.array(z.object({ id: z.string().nullable(), reason: z.string() })),
/** Every open tab of the venue: the terminal replaces its own with these. */
tabs: z.array(terminalTabSchema),
/** Every product, archived ones included; null when catalogVersion is still current. */
catalog: z.array(terminalProductSchema).nullable(),
catalogVersion: z.string(),
/** Every product's stock; null when not tracked. Can be negative: a sale is never blocked. */
stock: z.array(z.object({ productId: id, stock: z.int().nullable(), countedAt: isoDate.nullable() })),
/** New settlements of the venue (also from other terminals and the dashboard), oldest first. */
settlements: z.array(terminalSettlementSchema),
settlementsCursor: z.string(),
});
export type Unit = z.output<typeof unitSchema>;
export type TerminalLine = z.output<typeof lineSchema>;
export type TerminalTab = z.output<typeof terminalTabSchema>;
export type TerminalProduct = z.output<typeof terminalProductSchema>;
export type TerminalSettlement = z.output<typeof terminalSettlementSchema>;
export type SyncResponse = z.output<typeof syncResponseSchema>;
export const imageUploadResponseSchema = z.object({ imageId: z.string() });
-25
View File
@@ -1,25 +0,0 @@
export type DeviceStatus = 'online' | 'recently_seen' | 'stale' | 'offline' | 'revoked';
export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired';
export type CommandType = 'close_tab';
export type PaymentMethod = 'cash' | 'payconiq' | 'payment_pending';
export type ClientUserRole = 'admin' | 'manager';
export type ActorType = 'client_user' | 'device' | 'system';
export type AuditAction =
| 'command_created'
| 'command_acknowledged'
| 'command_expired'
| 'device_registered'
| 'device_revoked'
| 'snapshot_conflict'
| 'stock_adjustments_created'
| 'stock_adjustment_cancelled';
export type StockAdjustmentType = 'add' | 'set';
export type StockAdjustmentStatus = 'pending' | 'applied' | 'rejected' | 'cancelled';
+1 -1
View File
@@ -5,4 +5,4 @@
"rootDir": "src"
},
"include": ["src"]
}
}