feat: add stuff
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 14:45:22 +02:00
parent ee06af8395
commit 1e4438ec5a
24 changed files with 1734 additions and 14 deletions
+91 -1
View File
@@ -3,13 +3,20 @@ import { DevicesService } from './devices.service';
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
type StoredProduct = { productId: string; name: string; category: string | null; priceInCents: number; stock: number | null };
function createService({
previous = [] as Snapshot[], delivered = 0, expired = 0, products = [] as StoredProduct[], pendingStock = [] as unknown[],
recordedResults = 1,
} = {}) {
const tx = {
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() },
tabEvent: { createMany: jest.fn() },
product: { deleteMany: jest.fn(), updateMany: jest.fn(), upsert: jest.fn() },
stockAdjustment: { updateMany: jest.fn().mockResolvedValue({ count: recordedResults }) },
};
const prisma = {
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
@@ -17,6 +24,8 @@ function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0
$transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) },
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) },
product: { findMany: jest.fn().mockResolvedValue(products) },
stockAdjustment: { findMany: jest.fn().mockResolvedValue(pendingStock) },
};
const audit = { log: jest.fn() };
const realtime = { notify: jest.fn() };
@@ -201,3 +210,84 @@ describe('DevicesService.sync tab events', () => {
expect(result.acknowledgedEventIds).toEqual([]);
});
});
describe('DevicesService.sync stock', () => {
const cola = { id: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
const storedCola = { productId: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
it('leaves products alone when the terminal does not report them', async () => {
const { service, prisma, tx } = createService({ products: [storedCola] });
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.product.findMany).not.toHaveBeenCalled();
expect(tx.product.deleteMany).not.toHaveBeenCalled();
});
it('only writes changed products, removes missing ones and signals "stock"', async () => {
const storedWater = { productId: 'p2', name: 'Water', category: null, priceInCents: 200, stock: null };
const { service, tx, realtime } = createService({ products: [storedCola, storedWater] });
await service.sync(device, {
deviceId: 'd1',
openTabs: [],
products: [{ ...cola, stock: 46 }, { id: 'p3', name: ' Bier ', priceInCents: 300, stock: null }],
});
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1', 'p3'] } } });
expect(tx.product.upsert).toHaveBeenCalledTimes(2);
expect(tx.product.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ productId: 'p3', name: 'Bier', category: null, stock: null }),
}));
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('does not signal anything for an unchanged catalog', async () => {
const { service, tx, realtime } = createService({ products: [storedCola], recordedResults: 0 });
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola] });
expect(tx.product.upsert).not.toHaveBeenCalled();
expect(realtime.notify).not.toHaveBeenCalled();
});
it('skips an invalid product without failing the sync', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola, { id: 'p9', name: 'Broken', priceInCents: -1, stock: 3 }] });
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1'] } } });
});
it('records stock adjustment results and acknowledges them', async () => {
const { service, tx, realtime } = createService();
const id = '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c';
const result = await service.sync(device, {
deviceId: 'd1',
openTabs: [],
stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }, { id: 'nope', status: 'applied' }],
});
expect(tx.stockAdjustment.updateMany).toHaveBeenCalledWith({
where: { id, venueId: 'v1', status: { in: ['pending', 'cancelled'] } },
data: { status: 'applied', stockAfter: 72, result: null, processedAt: expect.any(Date) },
});
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
expect(result.rejectedStockAdjustmentResults.map((r) => r.id)).toEqual(['nope']);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('sends the pending stock adjustments of the venue', async () => {
const pending = [{ id: 'a1', productId: 'p1', type: 'add', quantity: 24, note: null, requestedBy: 'Bram', createdAt: new Date() }];
const { service, prisma } = createService({ pendingStock: pending });
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.stockAdjustment.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { venueId: 'v1', status: 'pending' },
orderBy: { createdAt: 'asc' },
}));
expect(result.stockAdjustments).toEqual(pending);
});
});
+84
View File
@@ -15,6 +15,7 @@ import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
import { customerNameKey } from '../planned-tabs/customer-name';
import { AckCommandDto } from './dto/ack-command.dto';
import { parseTabEvents, tabEventRow } from './tab-events';
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
@Injectable()
export class DevicesService {
@@ -39,6 +40,15 @@ export class DevicesService {
for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
}
// Omitted by app versions without stock: the products are then left as they are
const reported = dto.products ? parseProducts(dto.products) : null;
for (const { id, reason } of reported?.rejected ?? []) {
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
}
const stockResults = parseStockResults(dto.stockAdjustmentResults ?? []);
for (const { id, reason } of stockResults.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
}
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
@@ -67,6 +77,22 @@ export class DevicesService {
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
let plannedDelivered = 0;
// Products belong to the venue rather than the device, so they survive pairing a new terminal
const previousProducts = reported
? await this.prisma.product.findMany({
where: { venueId: device.venueId },
select: { productId: true, name: true, category: true, priceInCents: true, stock: true },
})
: [];
const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p]));
const changedProducts = (reported?.products ?? []).filter((p) => {
const stored = previousProductsById.get(p.id);
return !stored || !sameProduct(stored, p);
});
const productsChanged = reported !== null
&& (previousProducts.length !== reported.products.length || changedProducts.length > 0);
let stockResultsRecorded = 0;
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
@@ -148,6 +174,43 @@ export class DevicesService {
}));
}
if (reported) {
const reportedIds = reported.products.map((p) => p.id);
await tx.product.deleteMany({ where: { venueId: device.venueId, productId: { notIn: reportedIds } } });
await tx.product.updateMany({
where: { venueId: device.venueId, productId: { in: reportedIds } },
data: { reportedAt: now },
});
for (const product of changedProducts) {
const data = {
name: product.name,
category: product.category,
priceInCents: product.priceInCents,
stock: product.stock,
reportedAt: now,
};
await tx.product.upsert({
where: { venueId_productId: { venueId: device.venueId, productId: product.id } },
create: { venueId: device.venueId, productId: product.id, ...data },
update: data,
});
}
}
// The terminal's outcome is the truth, even for an adjustment cancelled while it was on its way
for (const result of stockResults.accepted) {
const { count } = await tx.stockAdjustment.updateMany({
where: { id: result.id, venueId: device.venueId, status: { in: ['pending', 'cancelled'] } },
data: {
status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null,
result: result.reason ?? null,
processedAt: now,
},
});
stockResultsRecorded += count;
}
if (events.accepted.length > 0) {
await tx.tabEvent.createMany({
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
@@ -203,10 +266,19 @@ export class DevicesService {
select: { customerName: true },
});
// Sent on every sync until the terminal reports an outcome; the terminal dedupes by id
const stockAdjustments = await this.prisma.stockAdjustment.findMany({
where: { venueId: device.venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
take: 500,
select: { id: true, productId: true, type: true, quantity: true, note: true, requestedBy: true, createdAt: true },
});
const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
if (productsChanged || stockResultsRecorded > 0) topics.push('stock');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
@@ -227,6 +299,11 @@ export class DevicesService {
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never stored: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
stockAdjustments,
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected,
};
}
@@ -314,6 +391,13 @@ export class DevicesService {
}
}
type StoredProduct = { name: string; category: string | null; priceInCents: number; stock: number | null };
function sameProduct(stored: StoredProduct, reported: ParsedProduct): boolean {
return stored.name === reported.name && stored.category === reported.category
&& stored.priceInCents === reported.priceInCents && stored.stock === reported.stock;
}
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
@@ -0,0 +1,55 @@
import { IsString, IsIn, IsInt, Min, IsNotEmpty, MaxLength, IsOptional, IsUUID, ValidateIf } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportedProductDto {
@ApiProperty({ example: 'local-product-id', description: "The terminal's own id for the product" })
@IsString()
@IsNotEmpty()
@MaxLength(100)
id: string;
@ApiProperty({ example: 'Cola' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank' })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
priceInCents: number;
@ApiProperty({ example: 48, nullable: true, description: 'Null when stock is not tracked; may be negative' })
@ValidateIf((product: ReportedProductDto) => product.stock !== null)
@IsInt()
stock: number | null;
}
export const STOCK_RESULT_STATUSES = ['applied', 'rejected'] as const;
export class StockAdjustmentResultDto {
@ApiProperty({ description: 'The stock adjustment id from the sync response' })
@IsUUID()
id: string;
@ApiProperty({ enum: STOCK_RESULT_STATUSES })
@IsIn(STOCK_RESULT_STATUSES)
status: (typeof STOCK_RESULT_STATUSES)[number];
@ApiPropertyOptional({ example: 72, description: 'applied: the stock right after applying it' })
@ValidateIf((result: StockAdjustmentResultDto) => result.status === 'applied')
@IsInt()
stockAfter?: number;
@ApiPropertyOptional({ example: 'unknown_product', description: 'rejected: why' })
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}
+16 -1
View File
@@ -77,4 +77,19 @@ export class SyncDto {
@IsArray()
@ArrayMaxSize(1000)
events?: unknown[];
}
// Validated one by one in the service, like events
@ApiPropertyOptional({
description: "ReportedProductDto[]: the terminal's complete product catalog with stock. Omitted by app versions without stock",
})
@IsOptional()
@IsArray()
@ArrayMaxSize(2000)
products?: unknown[];
@ApiPropertyOptional({ description: 'StockAdjustmentResultDto[]: outcomes of stock adjustments the terminal received' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
stockAdjustmentResults?: unknown[];
}
+54
View File
@@ -0,0 +1,54 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ReportedProductDto, StockAdjustmentResultDto } from './dto/stock-sync.dto';
import { describe } from './tab-events';
export type Rejected = { id: string | null; reason: string };
/**
* Validates entries one by one, like tab events: one bad product or result is skipped on its own
* instead of failing the sync (and with it the open tabs and sales).
*/
function parseEach<T extends object>(cls: new () => T, raw: unknown[]): { accepted: T[]; rejected: Rejected[] } {
const accepted: T[] = [];
const rejected: Rejected[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'must be an object' });
continue;
}
const entry = plainToInstance(cls, value);
const errors = validateSync(entry, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
accepted.push(entry);
}
return { accepted, rejected };
}
export function parseProducts(raw: unknown[]) {
const { accepted, rejected } = parseEach(ReportedProductDto, raw);
// A duplicated id would make the upserts fight; the last one reported wins
const byId = new Map(accepted.map((product) => [product.id, product]));
return {
products: [...byId.values()].map((p) => ({
id: p.id,
name: p.name.trim(),
category: p.category?.trim() || null,
priceInCents: p.priceInCents,
stock: p.stock ?? null,
})),
rejected,
};
}
export type ParsedProduct = ReturnType<typeof parseProducts>['products'][number];
export function parseStockResults(raw: unknown[]) {
return parseEach(StockAdjustmentResultDto, raw);
}
+1 -1
View File
@@ -64,7 +64,7 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
};
}
function describe(errors: ValidationError[]): string {
export function describe(errors: ValidationError[]): string {
return errors
.flatMap((error) => [
...Object.values(error.constraints ?? {}),