feat: add image support to the products
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-21 12:45:19 +02:00
parent ec77dd0e27
commit 5c604e0851
38 changed files with 1241 additions and 77 deletions
+19 -1
View File
@@ -8,9 +8,14 @@ import {
HttpCode,
HttpStatus,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { SkipThrottle, Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes } from '@nestjs/swagger';
import { ImagesService } from '../images/images.service';
import { MAX_UPLOAD_BYTES } from '../images/image-processing';
import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -27,6 +32,7 @@ export class DevicesController {
constructor(
private devicesService: DevicesService,
private authService: AuthService,
private images: ImagesService,
) {}
@Post('register')
@@ -48,6 +54,18 @@ export class DevicesController {
return this.devicesService.sync(req.user, dto);
}
// A picture taken on a terminal. Only stored: the terminal links it to its product with a product_updated event,
// so an offline edit and its picture follow the same rules as every other change
@Post(':deviceId/images')
@SkipThrottle()
@UseGuards(DeviceAuthGuard)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } }))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload a product picture from a terminal' })
async uploadImage(@UploadedFile() file: Express.Multer.File | undefined) {
return { imageId: await this.images.store(file?.buffer) };
}
@Post(':deviceId/commands/:commandId/ack')
@SkipThrottle()
@UseGuards(DeviceAuthGuard)
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DevicesController } from './devices.controller';
import { DevicesService } from './devices.service';
import { ImagesModule } from '../images/images.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, ImagesModule],
controllers: [DevicesController],
providers: [DevicesService],
exports: [DevicesService],
@@ -44,6 +44,8 @@ const events = {
amountInCents: quantity * 250, paymentMethod: 'payconiq',
items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }],
}),
product: (type: string, productId: string, product: Record<string, unknown> | undefined, minutes = 0) => (
{ id: uuidv4(), type, productId, ...(product ? { product } : {}), occurredAt: at(minutes) }),
stockSet: (productId: string, quantity: number | null, minutes = 0) => (
{ id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }),
};
@@ -531,3 +533,134 @@ describe('DevicesService.sync: website product changes reach the tablets', () =>
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', name: 'Coca-Cola' })]);
});
});
describe('DevicesService.sync: products created and changed on a tablet', () => {
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
function setupWithWebsite() {
const venue = setup();
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
return { ...venue, stock };
}
const catalogEntry = (result: SyncResult, id: string) => result.catalog.find((p) => p.id === id);
it('creates a product made offline, and sells and counts it in the same sync', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, {
events: [
events.product('product_created', 'iced', { name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3 }, 1),
events.stockSet('iced', 12, 2),
events.opened('t1', 'Alice', 3),
events.line('line_added', 't1', 2, { minutes: 4, productId: 'iced', productName: 'Ice Coffee', price: 350 }),
],
});
const result = await sync(tabletB);
expect(catalogEntry(result, 'iced')).toEqual(
{ id: 'iced', name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3, archived: false, imageId: null });
expect(result.stock.find((s) => s.productId === 'iced')?.stock).toBe(10);
});
it('keeps both a website price change and an offline rename of the same product', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
// Renamed offline before the price change, synced after it
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 5)] });
expect(catalogEntry(result, 'cola')).toMatchObject({ name: 'Coca-Cola', priceInCents: 270 });
expect(prisma.product.rows[0].fieldUpdatedAt).toMatchObject({ name: at(5) });
});
it('lets the latest change of a field win, also when the older one arrives last', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await sync(tabletB, { events: [events.product('product_updated', 'cola', { priceInCents: 300 }, 20)] });
// Tablet A was offline: its older price change arrives later and loses
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 10)] });
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(300);
});
it('a website change made after a tablet change wins', async () => {
const { sync, tabletA, stock } = setupWithWebsite();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 1)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 260 }, admin);
const result = await sync(tabletA);
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(260);
});
it('archives and restores a product from a tablet', async () => {
const { sync, tabletA } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
const archived = await sync(tabletA, { events: [events.product('product_archived', 'cola', undefined, 1)] });
expect(catalogEntry(archived, 'cola')!.archived).toBe(true);
const restored = await sync(tabletA, { events: [events.product('product_restored', 'cola', undefined, 2)] });
expect(catalogEntry(restored, 'cola')!.archived).toBe(false);
});
it('only links a picture that was uploaded', async () => {
const { sync, tabletA, prisma } = setup();
prisma.productImage.rows.push({ id: 'a'.repeat(64), contentType: 'image/webp', width: 512, height: 512, bytes: Buffer.from('x') });
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
const unknown = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'b'.repeat(64) }, 1)] });
expect(catalogEntry(unknown, 'cola')!.imageId).toBeNull();
const known = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'a'.repeat(64) }, 2)] });
expect(catalogEntry(known, 'cola')!.imageId).toBe('a'.repeat(64));
const removed = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: null }, 3)] });
expect(catalogEntry(removed, 'cola')!.imageId).toBeNull();
});
it('accepts two products with the same name from two offline tablets', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola-a', { name: 'Cola', priceInCents: 250 }, 0)] });
const result = await sync(tabletB, { events: [events.product('product_created', 'cola-b', { name: 'cola', priceInCents: 250 }, 0)] });
expect(result.catalog.map((p) => p.id).sort()).toEqual(['cola-a', 'cola-b']);
});
it('acknowledges a change to a product the venue does not have, without creating it', async () => {
const { sync, tabletA } = setup();
const change = events.product('product_updated', 'gone', { name: 'Gone' }, 0);
const result = await sync(tabletA, { events: [change] });
expect(result.acknowledgedEventIds).toEqual([change.id]);
expect(result.catalog).toEqual([]);
});
it('rejects a new product without a price', async () => {
const { sync, tabletA } = setup();
const incomplete = events.product('product_created', 'x', { name: 'X' }, 0);
const result = await sync(tabletA, { events: [incomplete] });
expect(result.rejectedEvents).toEqual([{ id: incomplete.id, reason: 'a new product needs a name and a price' }]);
});
it('applies a product event once, also when it is sent again', async () => {
const { sync, tabletA, tabletB } = setup();
const created = events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0);
const renamed = events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 1);
await sync(tabletA, { events: [created, renamed] });
await sync(tabletB, { events: [events.product('product_updated', 'cola', { name: 'Cola Classic' }, 2)] });
// The old rename again, with its id: already applied, so it must not undo the newer name
const result = await sync(tabletA, { events: [renamed] });
expect(catalogEntry(result, 'cola')!.name).toBe('Cola Classic');
});
});
+14 -2
View File
@@ -19,6 +19,8 @@ import { VenueLedger } from '../tabs/venue-ledger';
import { seedTabs, tabJson } from '../tabs/tab-state';
import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
import { applyPendingAdjustments } from '../stock/stock-adjustments';
import { applyProductEvent } from '../stock/product-events';
import { isProductEvent } from './dto/tab-event.dto';
@Injectable()
export class DevicesService {
@@ -59,7 +61,7 @@ export class DevicesService {
const ledger = await VenueLedger.open(tx, venueId);
// 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
const productsChanged = reported && !ledger.seeded
let productsChanged = reported && !ledger.seeded
? await this.saveProducts(tx, ledger, reported.products, now)
: false;
@@ -83,6 +85,11 @@ export class DevicesService {
} else {
for (const event of fresh) {
const occurredAt = eventTime(event, now);
// Products first: a sale or count of a product created offline arrives in the same request
if (isProductEvent(event)) {
if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true;
continue;
}
const settlement = ledger.tabs.apply(event, occurredAt);
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
ledger.stock.apply(event, occurredAt);
@@ -117,7 +124,10 @@ export class DevicesService {
const catalog = await tx.product.findMany({
where: { venueId },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
select: { productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true },
select: {
productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true,
imageId: true,
},
});
return {
@@ -198,6 +208,8 @@ export class DevicesService {
priceInCents: p.priceInCents,
lowStockThreshold: p.lowStockThreshold,
archived: p.archivedAt !== null,
// The picture is at /images/<imageId>; null: none
imageId: p.imageId,
})),
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
+50 -3
View File
@@ -1,5 +1,5 @@
import {
IsString, IsIn, IsInt, Min, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
IsString, IsIn, IsInt, Min, Max, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
ArrayMaxSize, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
@@ -8,7 +8,10 @@ import { OpenTabItemDto } from './sync.dto';
export const TAB_EVENT_TYPES = [
'tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set',
'product_created', 'product_updated', 'product_archived', 'product_restored',
] as const;
export const PRODUCT_EVENT_TYPES = ['product_created', 'product_updated', 'product_archived', 'product_restored'] as const;
export type TabEventType = (typeof TAB_EVENT_TYPES)[number];
export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const;
@@ -16,7 +19,45 @@ export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending'
const isLine = (event: TabEventDto) => event.type === 'line_added' || event.type === 'line_removed';
const isSettlement = (event: TabEventDto) => event.type === 'settled';
const isStockSet = (event: TabEventDto) => event.type === 'stock_set';
const isAboutTab = (event: TabEventDto) => !isStockSet(event);
export const isProductEvent = (event: TabEventDto) => (PRODUCT_EVENT_TYPES as readonly string[]).includes(event.type);
const isAboutTab = (event: TabEventDto) => !isStockSet(event) && !isProductEvent(event);
const carriesProduct = (event: TabEventDto) => event.type === 'product_created' || event.type === 'product_updated';
/** A product as created or changed on a terminal: only the fields that changed are sent. */
export class ProductEventFieldsDto {
@ApiPropertyOptional({ example: 'Cola' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiPropertyOptional({ example: 250 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents?: number;
@ApiPropertyOptional({ example: 12, nullable: true })
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold?: number | null;
@ApiPropertyOptional({ description: 'A picture uploaded to /devices/:deviceId/images first; null removes it', nullable: true })
@IsOptional()
@IsString()
@MaxLength(64)
imageId?: string | null;
}
export class SettledItemDto extends OpenTabItemDto {
@ApiPropertyOptional({ example: '12', description: 'Missing in events from older app versions' })
@@ -64,7 +105,7 @@ export class TabEventDto {
lineId?: string;
@ApiPropertyOptional({ example: '12', description: 'line_added / line_removed (missing from older app versions), stock_set' })
@ValidateIf((event: TabEventDto) => isStockSet(event) || event.productId !== undefined)
@ValidateIf((event: TabEventDto) => isStockSet(event) || isProductEvent(event) || event.productId !== undefined)
@IsString()
@IsNotEmpty()
@MaxLength(100)
@@ -109,6 +150,12 @@ export class TabEventDto {
@IsUUID()
commandId?: string;
@ApiPropertyOptional({ type: ProductEventFieldsDto, description: 'product_created / product_updated' })
@ValidateIf(carriesProduct)
@ValidateNested()
@Type(() => ProductEventFieldsDto)
product?: ProductEventFieldsDto;
@ApiPropertyOptional({ type: [SettledItemDto], description: 'settled: the lines that were settled' })
@IsOptional()
@IsArray()
+12 -1
View File
@@ -1,7 +1,7 @@
import { plainToInstance } from 'class-transformer';
import { validateSync, ValidationError } from 'class-validator';
import { Prisma } from '@prisma/client';
import { TabEventDto } from './dto/tab-event.dto';
import { isProductEvent, TabEventDto } from './dto/tab-event.dto';
// A terminal clock that runs ahead must not put sales in the future
const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000;
@@ -30,6 +30,10 @@ export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejec
rejected.push({ id, reason: 'quantity must not be less than 1' });
continue;
}
if (event.type === 'product_created' && (!event.product?.name || event.product.priceInCents === undefined)) {
rejected.push({ id, reason: 'a new product needs a name and a price' });
continue;
}
accepted.push(event);
}
@@ -57,6 +61,13 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
if (event.type === 'stock_set') {
return { ...base, productId: event.productId, stockSetTo: event.quantity ?? null };
}
if (isProductEvent(event)) {
return {
...base,
productId: event.productId,
productData: event.product ? ({ ...event.product } as Prisma.InputJsonValue) : Prisma.JsonNull,
};
}
if (event.type === 'tab_opened' || event.type === 'tab_renamed' || event.type === 'tab_deleted') {
return base;
}