@@ -0,0 +1 @@
|
||||
schema/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './constants';
|
||||
export * from './terminal';
|
||||
export * from './dashboard';
|
||||
|
||||
@@ -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() });
|
||||
@@ -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';
|
||||
@@ -5,4 +5,4 @@
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user