diff --git a/apps/api/package.json b/apps/api/package.json index d8f611f..da1d448 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,6 +40,7 @@ "prisma": "^5.10.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", + "sharp": "0.33.5", "socket.io": "^4.8.1", "ts-node": "^10.9.2", "typescript": "^5.4.0", @@ -52,6 +53,7 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.21", "@types/jest": "^29.5.11", + "@types/multer": "1.4.12", "@types/node": "^20.11.0", "@types/passport-jwt": "^4.0.0", "@types/supertest": "^6.0.3", diff --git a/apps/api/prisma/migrations/20261024120000_product_images_and_events/migration.sql b/apps/api/prisma/migrations/20261024120000_product_images_and_events/migration.sql new file mode 100644 index 0000000..b5b0a7f --- /dev/null +++ b/apps/api/prisma/migrations/20261024120000_product_images_and_events/migration.sql @@ -0,0 +1,28 @@ +-- AlterTable +ALTER TABLE "ClientUser" ALTER COLUMN "role" SET DEFAULT 'manager'; + +-- AlterTable +ALTER TABLE "TabEvent" ADD COLUMN "productData" JSONB; + +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "fieldUpdatedAt" JSONB, +ADD COLUMN "imageId" TEXT; + +-- CreateTable +CREATE TABLE "ProductImage" ( + "id" TEXT NOT NULL, + "contentType" TEXT NOT NULL, + "width" INTEGER NOT NULL, + "height" INTEGER NOT NULL, + "bytes" BYTEA NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE; + + +-- "viewer" was the name for the people running the venue: they are managers now +UPDATE "ClientUser" SET "role" = 'manager' WHERE "role" = 'viewer'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b916233..6ee63b6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -59,7 +59,8 @@ model ClientUser { email String @unique passwordHash String name String - role String @default("viewer") + // admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders) + role String @default("manager") createdAt DateTime @default(now()) venue Venue @relation(fields: [venueId], references: [id]) @@ -222,6 +223,8 @@ model TabEvent { items Json? // stock_set: the counted stock, null to stop tracking stockSetTo Int? + // product_created / product_updated: the product's fields as sent by the terminal + productData Json? occurredAt DateTime receivedAt DateTime @default(now()) @@ -252,6 +255,12 @@ model Product { reportedAt DateTime // Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it archivedAt DateTime? + imageId String? + // When each field was last changed ({ name: ISO date, ... }): a change from the dashboard or an (offline) + // terminal only wins over a later one per field, so edits of different fields never overwrite each other + fieldUpdatedAt Json? + + image ProductImage? @relation(fields: [imageId], references: [id]) venue Venue @relation(fields: [venueId], references: [id]) @@ -330,3 +339,16 @@ model Settlement { @@index([venueId, sequence]) @@index([venueId, settledAt]) } + +// A product picture, resized and cropped on upload. The id is the SHA-256 of the stored bytes, so the picture at +// an address never changes and can be cached forever, and the same picture is stored once. +model ProductImage { + id String @id + contentType String + width Int + height Int + bytes Bytes + createdAt DateTime @default(now()) + + products Product[] +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 7149012..432c861 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -50,7 +50,7 @@ async function main() { email: 'viewer@example.com', passwordHash: viewerHash, name: 'Bartender', - role: 'viewer', + role: 'manager', }, }); console.log('Created viewer user: viewer@example.com / Viewer123!'); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4f307dd..ea34652 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -13,6 +13,7 @@ import { JobsModule } from './jobs/jobs.module'; import { PlannedTabsModule } from './planned-tabs/planned-tabs.module'; import { RemindersModule } from './reminders/reminders.module'; import { StockModule } from './stock/stock.module'; +import { ImagesModule } from './images/images.module'; import { RealtimeModule } from './realtime/realtime.module'; import { EmailModule } from './email/email.module'; import { HealthController } from './health.controller'; @@ -34,6 +35,7 @@ import { HealthController } from './health.controller'; PlannedTabsModule, RemindersModule, StockModule, + ImagesModule, ], controllers: [HealthController], providers: [ diff --git a/apps/api/src/common/guards/roles.guard.spec.ts b/apps/api/src/common/guards/roles.guard.spec.ts new file mode 100644 index 0000000..f156d87 --- /dev/null +++ b/apps/api/src/common/guards/roles.guard.spec.ts @@ -0,0 +1,31 @@ +import { RolesGuard } from './roles.guard'; + +function context(role: string | undefined) { + return { + getHandler: () => null, + getClass: () => null, + switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }), + } as any; +} + +function guard(required: string[] | undefined) { + return new RolesGuard({ getAllAndOverride: () => required } as any); +} + +describe('RolesGuard', () => { + it('lets managers do what managers may, and keeps admin-only actions for the admin', () => { + expect(guard(['admin', 'manager']).canActivate(context('manager'))).toBe(true); + expect(guard(['admin']).canActivate(context('manager'))).toBe(false); + expect(guard(['admin']).canActivate(context('admin'))).toBe(true); + }); + + it('treats a token from before the rename ("viewer") as a manager', () => { + expect(guard(['admin', 'manager']).canActivate(context('viewer'))).toBe(true); + expect(guard(['admin']).canActivate(context('viewer'))).toBe(false); + }); + + it('refuses a request without a user when a role is required', () => { + expect(guard(['manager']).canActivate(context(undefined))).toBe(false); + expect(guard(undefined).canActivate(context(undefined))).toBe(true); + }); +}); diff --git a/apps/api/src/common/guards/roles.guard.ts b/apps/api/src/common/guards/roles.guard.ts index a9e2f1d..49886c7 100644 --- a/apps/api/src/common/guards/roles.guard.ts +++ b/apps/api/src/common/guards/roles.guard.ts @@ -18,6 +18,8 @@ export class RolesGuard { if (!user) { return false; } - return requiredRoles.includes(user.role); + // "viewer" was the old name of a manager; tokens issued before the rename still carry it + const role = user.role === 'viewer' ? 'manager' : user.role; + return requiredRoles.includes(role); } } \ No newline at end of file diff --git a/apps/api/src/devices/devices.controller.ts b/apps/api/src/devices/devices.controller.ts index dbc116b..25f3dba 100644 --- a/apps/api/src/devices/devices.controller.ts +++ b/apps/api/src/devices/devices.controller.ts @@ -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) diff --git a/apps/api/src/devices/devices.module.ts b/apps/api/src/devices/devices.module.ts index 86229fd..962c987 100644 --- a/apps/api/src/devices/devices.module.ts +++ b/apps/api/src/devices/devices.module.ts @@ -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], diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index 6bb0925..725a1df 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -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 | 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'); + }); +}); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 595bc31..6fcdad0 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -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/; null: none + imageId: p.imageId, })), stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => ( { productId, stock, countedAt: countedAt?.toISOString() ?? null })), diff --git a/apps/api/src/devices/dto/tab-event.dto.ts b/apps/api/src/devices/dto/tab-event.dto.ts index 6cdfaba..91d34d9 100644 --- a/apps/api/src/devices/dto/tab-event.dto.ts +++ b/apps/api/src/devices/dto/tab-event.dto.ts @@ -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() diff --git a/apps/api/src/devices/tab-events.ts b/apps/api/src/devices/tab-events.ts index 0380df4..682d38e 100644 --- a/apps/api/src/devices/tab-events.ts +++ b/apps/api/src/devices/tab-events.ts @@ -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; } diff --git a/apps/api/src/images/image-processing.spec.ts b/apps/api/src/images/image-processing.spec.ts new file mode 100644 index 0000000..e105461 --- /dev/null +++ b/apps/api/src/images/image-processing.spec.ts @@ -0,0 +1,87 @@ +import sharp from 'sharp'; +import { createHash } from 'crypto'; +import { IMAGE_SIZE, InvalidImageError, processProductImage } from './image-processing'; + +/** A white canvas with a coloured rectangle on it, like a studio shot of a product. */ +async function shot(width: number, height: number, product: { width: number; height: number }, format: 'jpeg' | 'png' = 'png') { + const box = await sharp({ + create: { width: product.width, height: product.height, channels: 3, background: { r: 200, g: 30, b: 30 } }, + }).png().toBuffer(); + return sharp({ create: { width, height, channels: 3, background: { r: 255, g: 255, b: 255 } } }) + .composite([{ input: box, gravity: 'centre' }]) + [format]() + .toBuffer(); +} + +/** A busy photo without a plain background: noise everywhere. */ +async function photo(width: number, height: number) { + const pixels = Buffer.alloc(width * height * 3); + for (let i = 0; i < pixels.length; i++) pixels[i] = (i * 7919) % 256; + return sharp(pixels, { raw: { width, height, channels: 3 } }).jpeg().toBuffer(); +} + +async function alphaAt(image: Buffer, x: number, y: number) { + const { data, info } = await sharp(image).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + return data[(y * info.width + x) * info.channels + 3]; +} + +describe('processProductImage', () => { + it('stores a square WebP whose id is the hash of its bytes', async () => { + const image = await processProductImage(await photo(800, 600)); + + expect(image).toMatchObject({ contentType: 'image/webp', width: IMAGE_SIZE, height: IMAGE_SIZE }); + expect((await sharp(image.bytes).metadata()).format).toBe('webp'); + expect(image.id).toBe(createHash('sha256').update(image.bytes).digest('hex')); + }); + + it('gives the same picture the same id', async () => { + const input = await photo(640, 640); + + const [a, b] = await Promise.all([processProductImage(input), processProductImage(input)]); + + expect(a.id).toBe(b.id); + }); + + it('fits a tall bottle whole, with transparent sides, after trimming the white background', async () => { + // A bottle of 100x400 on a big white sheet: trimmed, it is 1:4, far from square + const image = await processProductImage(await shot(1000, 1000, { width: 100, height: 400 })); + + // Transparent at the sides, product in the middle from top to bottom + expect(await alphaAt(image.bytes, 5, IMAGE_SIZE / 2)).toBe(0); + expect(await alphaAt(image.bytes, IMAGE_SIZE / 2, 5)).toBe(255); + expect(await alphaAt(image.bytes, IMAGE_SIZE / 2, IMAGE_SIZE - 5)).toBe(255); + }); + + it('crops a roughly square photo to a square without empty sides', async () => { + const image = await processProductImage(await photo(800, 700)); + + expect(await alphaAt(image.bytes, 2, 2)).toBe(255); + expect(await alphaAt(image.bytes, IMAGE_SIZE - 2, IMAGE_SIZE - 2)).toBe(255); + }); + + it('turns a phone photo the right way up', async () => { + // Stored landscape (300x600 product on its side) with EXIF orientation 6: shown rotated a quarter turn + const sideways = await sharp(await shot(1200, 600, { width: 800, height: 200 }, 'jpeg')) + .withMetadata({ orientation: 6 }) + .jpeg() + .toBuffer(); + + const image = await processProductImage(sideways); + + // Upright the product is tall: fitted whole with transparent sides + expect(await alphaAt(image.bytes, 5, IMAGE_SIZE / 2)).toBe(0); + expect(await alphaAt(image.bytes, IMAGE_SIZE / 2, 5)).toBe(255); + }); + + it('drops metadata such as the GPS position', async () => { + const withExif = await sharp(await photo(600, 600)).withMetadata({ exif: { IFD0: { Artist: 'Bram' } } }).jpeg().toBuffer(); + + const image = await processProductImage(withExif); + + expect((await sharp(image.bytes).metadata()).exif).toBeUndefined(); + }); + + it('refuses something that is not an image', async () => { + await expect(processProductImage(Buffer.from('not a picture'))).rejects.toBeInstanceOf(InvalidImageError); + }); +}); diff --git a/apps/api/src/images/image-processing.ts b/apps/api/src/images/image-processing.ts new file mode 100644 index 0000000..14e1768 --- /dev/null +++ b/apps/api/src/images/image-processing.ts @@ -0,0 +1,61 @@ +import { createHash } from 'crypto'; +import sharp from 'sharp'; + +/** Product pictures are stored as squares of this size: sharp on a tablet tile, small enough to store in the database. */ +export const IMAGE_SIZE = 512; + +/** The largest upload accepted; phone photos are a few MB. */ +export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +/** Between these width/height ratios a photo is cropped to a square; outside them (bottles, cans) it is fitted whole. */ +const SQUARE_ENOUGH = { min: 0.75, max: 1.33 }; + +export type ProcessedImage = { id: string; contentType: string; width: number; height: number; bytes: Buffer }; + +export class InvalidImageError extends Error {} + +/** + * Turns an upload into a product picture: rotated the right way up (phone photos), a plain background trimmed + * away, then either cropped to a square around its most interesting part (a roughly square photo of a glass) or + * fitted whole into a transparent square (a tall bottle, which must never be cut off). Metadata such as GPS + * position is dropped. The id is a hash of the result, so the same picture always gets the same address. + */ +export async function processProductImage(input: Buffer): Promise { + let upright: Buffer; + try { + upright = await sharp(input, { limitInputPixels: 50_000_000 }).rotate().toBuffer(); + } catch { + throw new InvalidImageError('Not an image this server can read (JPEG, PNG, WebP, GIF or AVIF)'); + } + + const product = await trimBackground(upright); + const { width = 1, height = 1 } = await sharp(product).metadata(); + const ratio = width / height; + const square = ratio >= SQUARE_ENOUGH.min && ratio <= SQUARE_ENOUGH.max; + + const { data, info } = await sharp(product) + .resize(IMAGE_SIZE, IMAGE_SIZE, square + ? { fit: 'cover', position: sharp.strategy.attention } + : { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .webp({ quality: 82 }) + .toBuffer({ resolveWithObject: true }); + + return { + id: createHash('sha256').update(data).digest('hex'), + contentType: 'image/webp', + width: info.width, + height: info.height, + bytes: data, + }; +} + +/** Removes a plain border (a studio shot on white). Photos with a busy background come back as they were. */ +async function trimBackground(image: Buffer): Promise { + try { + const { data, info } = await sharp(image).trim({ threshold: 12 }).toBuffer({ resolveWithObject: true }); + // Trimming down to a sliver means the "background" was most of the picture: keep the original + return info.width >= 32 && info.height >= 32 ? data : image; + } catch { + return image; + } +} diff --git a/apps/api/src/images/images.controller.ts b/apps/api/src/images/images.controller.ts new file mode 100644 index 0000000..420087d --- /dev/null +++ b/apps/api/src/images/images.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Param, Res } from '@nestjs/common'; +import { SkipThrottle } from '@nestjs/throttler'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { ImagesService } from './images.service'; + +@ApiTags('Images') +@Controller('images') +export class ImagesController { + constructor(private images: ImagesService) {} + + /** + * Public: product pictures are not secret, and the dashboard's and the terminals load them without a + * token. The id is the hash of the content, so the response never changes and is cached for good. + */ + @Get(':id') + @SkipThrottle() + @ApiOperation({ summary: 'A product picture' }) + async get(@Param('id') id: string, @Res() res: Response) { + const image = await this.images.get(id.replace(/\.webp$/, '')); + res.set({ + 'Content-Type': image.contentType, + 'Content-Length': String(image.bytes.length), + 'Cache-Control': 'public, max-age=31536000, immutable', + ETag: `"${image.id}"`, + // Helmet's default (same-origin) would keep the dashboard on another domain from showing it + 'Cross-Origin-Resource-Policy': 'cross-origin', + }); + res.end(Buffer.from(image.bytes)); + } +} diff --git a/apps/api/src/images/images.module.ts b/apps/api/src/images/images.module.ts new file mode 100644 index 0000000..9010634 --- /dev/null +++ b/apps/api/src/images/images.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ImagesController } from './images.controller'; +import { ImagesService } from './images.service'; + +@Module({ + controllers: [ImagesController], + providers: [ImagesService], + exports: [ImagesService], +}) +export class ImagesModule {} diff --git a/apps/api/src/images/images.service.ts b/apps/api/src/images/images.service.ts new file mode 100644 index 0000000..a4e303a --- /dev/null +++ b/apps/api/src/images/images.service.ts @@ -0,0 +1,40 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { InvalidImageError, processProductImage } from './image-processing'; + +@Injectable() +export class ImagesService { + constructor(private prisma: PrismaService) {} + + /** Processes and stores an upload; storing the same picture twice keeps one copy. Returns its id. */ + async store(upload: Buffer | undefined): Promise { + if (!upload || upload.length === 0) { + throw new BadRequestException({ code: 'image_required', message: 'Send the picture as the "file" field' }); + } + + let image; + try { + image = await processProductImage(upload); + } catch (error) { + if (error instanceof InvalidImageError) { + throw new BadRequestException({ code: 'invalid_image', message: error.message }); + } + throw error; + } + + await this.prisma.productImage.upsert({ + where: { id: image.id }, + create: image, + update: {}, + }); + return image.id; + } + + async get(id: string) { + const image = await this.prisma.productImage.findUnique({ where: { id } }); + if (!image) { + throw new NotFoundException({ code: 'image_not_found', message: 'Image not found' }); + } + return image; + } +} diff --git a/apps/api/src/planned-tabs/planned-tabs.controller.ts b/apps/api/src/planned-tabs/planned-tabs.controller.ts index 44e7db8..2b232c6 100644 --- a/apps/api/src/planned-tabs/planned-tabs.controller.ts +++ b/apps/api/src/planned-tabs/planned-tabs.controller.ts @@ -23,14 +23,14 @@ export class PlannedTabsController { } @Post() - @Roles('admin') + @Roles('admin', 'manager') @ApiOperation({ summary: 'Add a planned tab name' }) async create(@Param('venueId') venueId: string, @Body() dto: CreatePlannedTabDto) { return this.service.create(venueId, dto.customerName, dto.email); } @Post('import') - @Roles('admin') + @Roles('admin', 'manager') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Bulk import planned tab names (e.g. from an Excel file)' }) async import(@Param('venueId') venueId: string, @Body() dto: ImportPlannedTabsDto) { @@ -38,14 +38,14 @@ export class PlannedTabsController { } @Put(':id') - @Roles('admin') + @Roles('admin', 'manager') @ApiOperation({ summary: 'Rename a planned tab' }) async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: UpdatePlannedTabDto) { return this.service.update(venueId, id, dto.customerName, dto.email); } @Delete(':id') - @Roles('admin') + @Roles('admin', 'manager') @ApiOperation({ summary: 'Remove a planned tab' }) async remove(@Param('venueId') venueId: string, @Param('id') id: string) { return this.service.remove(venueId, id); diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts index 6e3a426..e3d482a 100644 --- a/apps/api/src/reminders/reminders.controller.ts +++ b/apps/api/src/reminders/reminders.controller.ts @@ -23,7 +23,7 @@ export class RemindersController { @Post('send') @UseGuards(RolesGuard) - @Roles('admin') + @Roles('admin', 'manager') @ApiOperation({ summary: 'Send payment reminders via Resend (admin only)' }) @ApiResponse({ status: 201, description: 'Reminders sent' }) @ApiResponse({ status: 400, description: 'Resend not configured' }) @@ -42,7 +42,7 @@ export class RemindersController { @Post('test/:plannedTabId') @UseGuards(RolesGuard) - @Roles('admin') + @Roles('admin', 'manager') @ApiOperation({ summary: 'Send a test email to a planned customer (admin only)' }) @ApiResponse({ status: 201, description: 'Test email sent' }) @ApiResponse({ status: 400, description: 'Resend not configured or customer has no email' }) diff --git a/apps/api/src/stock/product-events.ts b/apps/api/src/stock/product-events.ts new file mode 100644 index 0000000..ef745c2 --- /dev/null +++ b/apps/api/src/stock/product-events.ts @@ -0,0 +1,70 @@ +import { Prisma } from '@prisma/client'; +import { TabEventDto } from '../devices/dto/tab-event.dto'; +import { VenueLedger } from '../tabs/venue-ledger'; +import { createdFieldTimes, mergeProductChanges, ProductChanges } from './product-fields'; + +type Tx = Prisma.TransactionClient; + +/** + * Applies a product created or changed on a terminal (also offline: [occurredAt] is when it happened there). + * A new product gets the terminal's id, so it can be sold before the terminal synced. Duplicate names are + * accepted, a sale must never be lost over one; the dashboard shows them so one can be archived. + * Returns whether anything changed. + */ +export async function applyProductEvent(tx: Tx, ledger: VenueLedger, event: TabEventDto, occurredAt: Date): Promise { + const { venueId } = ledger; + const productId = event.productId!; + const changes = await productChanges(tx, event); + + const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } }); + if (!existing) { + // Changes to a product the server doesn't have (deleted, or another venue's) are acknowledged and dropped + if (event.type !== 'product_created') return false; + + await tx.product.create({ + data: { + venueId, + productId, + name: changes.name!, + category: changes.category ?? null, + priceInCents: changes.priceInCents!, + lowStockThreshold: changes.lowStockThreshold ?? null, + imageId: changes.imageId ?? null, + // Its starting stock arrives as a stock_set event + stock: null, + reportedAt: occurredAt, + fieldUpdatedAt: createdFieldTimes({ ...changes, archived: false }, occurredAt), + }, + }); + ledger.stock.addProduct(productId); + return true; + } + + const merged = mergeProductChanges(existing.fieldUpdatedAt, changes, occurredAt, existing); + if (!merged.changed) return false; + await tx.product.update({ + where: { venueId_productId: { venueId, productId } }, + data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, reportedAt: occurredAt }, + }); + return true; +} + +async function productChanges(tx: Tx, event: TabEventDto): Promise { + const fields = event.product ?? {}; + const changes: ProductChanges = {}; + if (fields.name !== undefined) changes.name = fields.name.trim(); + // An empty category is no category + if (fields.category !== undefined) changes.category = fields.category?.trim() || null; + if (fields.priceInCents !== undefined) changes.priceInCents = fields.priceInCents; + if (fields.lowStockThreshold !== undefined) changes.lowStockThreshold = fields.lowStockThreshold; + if (event.type === 'product_archived') changes.archived = true; + if (event.type === 'product_restored') changes.archived = false; + + if (fields.imageId !== undefined) { + // Only a picture that was uploaded: the terminal uploads before it sends the change + const known = fields.imageId === null + || await tx.productImage.findUnique({ where: { id: fields.imageId }, select: { id: true } }); + if (known) changes.imageId = fields.imageId; + } + return changes; +} diff --git a/apps/api/src/stock/product-fields.ts b/apps/api/src/stock/product-fields.ts new file mode 100644 index 0000000..fbd8426 --- /dev/null +++ b/apps/api/src/stock/product-fields.ts @@ -0,0 +1,51 @@ +import { Prisma } from '@prisma/client'; + +/** The fields of a product that the dashboard and the terminals change; stock changes through its own rules. */ +export const PRODUCT_FIELDS = ['name', 'category', 'priceInCents', 'lowStockThreshold', 'archived', 'imageId'] as const; +export type ProductField = (typeof PRODUCT_FIELDS)[number]; + +export type ProductChanges = { + name?: string; + category?: string | null; + priceInCents?: number; + lowStockThreshold?: number | null; + archived?: boolean; + imageId?: string | null; +}; + +type FieldTimes = Partial>; + +/** + * Merges [changes] made at [at] into a product whose fields were last changed at [times]: per field, the latest + * change wins. A price changed on the dashboard and a name changed on an offline terminal both survive; a change + * that arrives late (a terminal coming back online) loses to a newer change of the same field. + * + * Returns the Prisma data to write (empty when nothing wins) and the product's new field times. + */ +export function mergeProductChanges(times: unknown, changes: ProductChanges, at: Date, current?: { archivedAt: Date | null }) { + const previous = (times && typeof times === 'object' ? times : {}) as FieldTimes; + const next: FieldTimes = { ...previous }; + const data: Prisma.ProductUncheckedUpdateInput = {}; + + for (const field of PRODUCT_FIELDS) { + const value = changes[field]; + if (value === undefined) continue; + const last = previous[field]; + if (last && new Date(last) > at) continue; + + next[field] = at.toISOString(); + if (field === 'archived') { + // Archiving again keeps the original archive time + data.archivedAt = value ? (current?.archivedAt ?? at) : null; + } else { + (data as Record)[field] = value; + } + } + + return { data, fieldUpdatedAt: next as Prisma.InputJsonValue, changed: Object.keys(data).length > 0 }; +} + +/** Field times for a product created at [at]: every field it was created with. */ +export function createdFieldTimes(changes: ProductChanges, at: Date): Prisma.InputJsonValue { + return Object.fromEntries(PRODUCT_FIELDS.filter((f) => changes[f] !== undefined).map((f) => [f, at.toISOString()])); +} diff --git a/apps/api/src/stock/stock.controller.ts b/apps/api/src/stock/stock.controller.ts index 955d9cf..df9fd4d 100644 --- a/apps/api/src/stock/stock.controller.ts +++ b/apps/api/src/stock/stock.controller.ts @@ -1,5 +1,10 @@ -import { Controller, Get, Post, Patch, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { + Controller, Get, Post, Patch, Delete, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req, UploadedFile, UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiConsumes } from '@nestjs/swagger'; +import { ImagesService } from '../images/images.service'; +import { MAX_UPLOAD_BYTES } from '../images/image-processing'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { VenueAccessGuard } from '../common/guards/venue-access.guard'; import { RolesGuard } from '../common/guards/roles.guard'; @@ -13,7 +18,7 @@ import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto'; @UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) @Controller('venues/:venueId/stock') export class StockController { - constructor(private service: StockService) {} + constructor(private service: StockService, private images: ImagesService) {} @Get() @ApiOperation({ summary: "The venue's products (also archived ones) with their stock" }) @@ -55,14 +60,14 @@ export class StockController { // The catalog is managed here; the terminals get it on every sync @Post('products') - @Roles('admin') + @Roles('admin', 'manager') @ApiOperation({ summary: 'Add a product (admin)' }) async createProduct(@Param('venueId') venueId: string, @Body() dto: CreateProductDto, @Req() req: any) { return this.service.createProduct(venueId, dto, req.user); } @Patch('products/:productId') - @Roles('admin') + @Roles('admin', 'manager') @ApiOperation({ summary: 'Change, archive (delete) or restore a product (admin)' }) async updateProduct( @Param('venueId') venueId: string, @@ -72,4 +77,26 @@ export class StockController { ) { return this.service.updateProduct(venueId, productId, dto, req.user); } + + @Post('products/:productId/image') + @Roles('admin', 'manager') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } })) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload a product picture: cropped to a square, stored as WebP' }) + async uploadImage( + @Param('venueId') venueId: string, + @Param('productId') productId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Req() req: any, + ) { + const imageId = await this.images.store(file?.buffer); + return this.service.setImage(venueId, productId, imageId, req.user); + } + + @Delete('products/:productId/image') + @Roles('admin', 'manager') + @ApiOperation({ summary: "Remove a product's picture" }) + async removeImage(@Param('venueId') venueId: string, @Param('productId') productId: string, @Req() req: any) { + return this.service.setImage(venueId, productId, null, req.user); + } } diff --git a/apps/api/src/stock/stock.module.ts b/apps/api/src/stock/stock.module.ts index 6cf062a..695775b 100644 --- a/apps/api/src/stock/stock.module.ts +++ b/apps/api/src/stock/stock.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { StockController } from './stock.controller'; import { StockService } from './stock.service'; +import { ImagesModule } from '../images/images.module'; @Module({ + imports: [ImagesModule], controllers: [StockController], providers: [StockService], }) diff --git a/apps/api/src/stock/stock.service.spec.ts b/apps/api/src/stock/stock.service.spec.ts index f0fb72d..b07d421 100644 --- a/apps/api/src/stock/stock.service.spec.ts +++ b/apps/api/src/stock/stock.service.spec.ts @@ -183,3 +183,35 @@ describe('StockService.findAll', () => { expect(products[0]).not.toHaveProperty('pendingAdjustments'); }); }); + +describe('StockService pictures and field times', () => { + const admin = { id: 'u1', email: 'bram@example.com', name: 'Bram' }; + + it('sets and removes a picture', async () => { + const { service, prisma } = createWithVenue([cola]); + + expect(await service.setImage('v1', 'p1', 'f'.repeat(64), admin)).toMatchObject({ imageId: 'f'.repeat(64) }); + expect(prisma.product.rows[0].imageId).toBe('f'.repeat(64)); + + expect(await service.setImage('v1', 'p1', null, admin)).toMatchObject({ imageId: null }); + }); + + it('a change on the dashboard wins even over a terminal clock running a little ahead', async () => { + const { service, prisma } = createWithVenue([cola]); + const ahead = new Date(Date.now() + 60_000).toISOString(); + prisma.product.rows[0].fieldUpdatedAt = { priceInCents: ahead }; + + await service.updateProduct('v1', 'p1', { priceInCents: 300 }, admin); + + expect(prisma.product.rows[0].priceInCents).toBe(300); + }); + + it('records when each field changed', async () => { + const { service, prisma } = createWithVenue([]); + + const created = await service.createProduct('v1', { name: 'Water', priceInCents: 200 }, admin); + const times = prisma.product.rows.find((p) => p.productId === created.productId)!.fieldUpdatedAt; + + expect(Object.keys(times).sort()).toEqual(['archived', 'category', 'lowStockThreshold', 'name', 'priceInCents']); + }); +}); diff --git a/apps/api/src/stock/stock.service.ts b/apps/api/src/stock/stock.service.ts index 134e7a6..27a8359 100644 --- a/apps/api/src/stock/stock.service.ts +++ b/apps/api/src/stock/stock.service.ts @@ -8,6 +8,7 @@ import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto'; import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto'; import { VenueLedger } from '../tabs/venue-ledger'; import { applyPendingAdjustments } from './stock-adjustments'; +import { createdFieldTimes, mergeProductChanges } from './product-fields'; const adjustmentSelect = { id: true, @@ -167,17 +168,21 @@ export class StockService { await this.ensureNameFree(tx, venueId, dto.name); const stock = dto.stock ?? null; + const fields = { + name: dto.name, + category: dto.category ?? null, + priceInCents: dto.priceInCents, + lowStockThreshold: dto.lowStockThreshold ?? null, + }; const created = await tx.product.create({ data: { venueId, productId: uuidv4(), - name: dto.name, - category: dto.category ?? null, - priceInCents: dto.priceInCents, + ...fields, stock, countedAt: stock === null ? null : now, - lowStockThreshold: dto.lowStockThreshold ?? null, reportedAt: now, + fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now), }, }); if (stock !== null) { @@ -227,16 +232,19 @@ export class StockService { await this.ensureNameFree(tx, venueId, name, productId); } + // A change made here is the latest change of each field it touches + const now = new Date(); + const merged = mergeProductChanges(existing.fieldUpdatedAt, { + ...(dto.name != null ? { name: dto.name } : {}), + ...(dto.category !== undefined ? { category: dto.category } : {}), + ...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}), + ...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}), + ...(dto.archived !== undefined ? { archived: dto.archived } : {}), + }, maxDate(now, existing.fieldUpdatedAt), existing); + return tx.product.update({ where: { venueId_productId: { venueId, productId } }, - data: { - name, - ...(dto.category !== undefined ? { category: dto.category } : {}), - ...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}), - ...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}), - archivedAt: archived ? (existing.archivedAt ?? new Date()) : null, - reportedAt: new Date(), - }, + data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, reportedAt: now }, }); }); @@ -246,6 +254,34 @@ export class StockService { return productView(product); } + /** + * Sets (or with null removes) a product's picture; the picture was stored first. Terminals download it on their + * next sync. + */ + async setImage(venueId: string, productId: string, imageId: string | null, user: { id: string }) { + const product = await this.prisma.$transaction(async (tx) => { + await VenueLedger.open(tx, venueId); + const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } }); + if (!existing) { + throw new NotFoundException({ code: 'product_not_found', message: 'Product not found' }); + } + + const now = new Date(); + const merged = mergeProductChanges(existing.fieldUpdatedAt, { imageId }, maxDate(now, existing.fieldUpdatedAt), existing); + return tx.product.update({ + where: { venueId_productId: { venueId, productId } }, + data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, reportedAt: now }, + }); + }); + + await this.auditService.log(venueId, 'client_user', user.id, imageId ? 'product_image_set' : 'product_image_removed', { + productId, imageId, + }); + this.realtime.notify(venueId, 'stock'); + + return productView(product); + } + private async ensureNameFree(tx: Prisma.TransactionClient, venueId: string, name: string, exceptProductId?: string) { const products = await tx.product.findMany({ where: { venueId, archivedAt: null }, @@ -258,9 +294,21 @@ export class StockService { } } +/** + * A change on the dashboard is the latest change of its fields. A terminal whose clock runs a little ahead may + * have stamped a field just after now: the dashboard change still wins, it was made last. + */ +function maxDate(now: Date, times: unknown): Date { + const stamps = Object.values((times && typeof times === 'object' ? times : {}) as Record) + .map((t) => new Date(t).getTime()) + .filter((t) => !Number.isNaN(t)); + return new Date(Math.max(now.getTime(), ...stamps)); +} + function productView(p: { productId: string; name: string; category: string | null; priceInCents: number; stock: number | null; lowStockThreshold: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null; + imageId?: string | null; }) { return { productId: p.productId, @@ -273,5 +321,6 @@ function productView(p: { countedAt: p.countedAt, reportedAt: p.reportedAt, archivedAt: p.archivedAt, + imageId: p.imageId ?? null, }; } diff --git a/apps/api/src/testing/fake-prisma.ts b/apps/api/src/testing/fake-prisma.ts index fb1d4e8..e48f939 100644 --- a/apps/api/src/testing/fake-prisma.ts +++ b/apps/api/src/testing/fake-prisma.ts @@ -158,7 +158,10 @@ export function createFakePrisma() { venue: new FakeTable(() => ({ tabsSeededAt: null })), device: new FakeTable(), tab: new FakeTable(() => ({ updatedAt: now() })), - product: new FakeTable(() => ({ stock: null, countedAt: null, lowStockThreshold: null, archivedAt: null })), + product: new FakeTable(() => ({ + stock: null, countedAt: null, lowStockThreshold: null, archivedAt: null, imageId: null, fieldUpdatedAt: null, + })), + productImage: new FakeTable(), tabEvent: new FakeTable(), settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'), stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })), diff --git a/apps/api/src/venues/dto/create-user.dto.ts b/apps/api/src/venues/dto/create-user.dto.ts index a1008bb..18de2b2 100644 --- a/apps/api/src/venues/dto/create-user.dto.ts +++ b/apps/api/src/venues/dto/create-user.dto.ts @@ -16,8 +16,8 @@ export class CreateUserDto { @IsNotEmpty() name: string; - @ApiProperty({ example: 'viewer', enum: ['admin', 'viewer'] }) + @ApiProperty({ example: 'manager', enum: ['admin', 'manager'], description: 'manager: runs the venue; admin: also users and terminals' }) @IsString() - @IsIn(['admin', 'viewer']) + @IsIn(['admin', 'manager']) role: string; } \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx index 0cb5ab6..227b3d5 100644 --- a/apps/dashboard/src/app/dashboard/reminders/page.tsx +++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx @@ -28,7 +28,8 @@ export default function RemindersPage() { const queryClient = useQueryClient(); const { t, tn, te, relativeTime, dateTime } = useLang(); const { venueId, role } = useSession(); - const isAdmin = role === 'admin'; + // Admins and managers both run the venue + const canManage = role === 'admin' || role === 'manager'; const [sendDialogOpen, setSendDialogOpen] = useState(false); const [showHistory, setShowHistory] = useState(false); const [search, setSearch] = useState(''); @@ -205,7 +206,7 @@ export default function RemindersPage() { className="pl-8" /> - {isAdmin && ( + {canManage && (
- {isAdmin ? ( + {canManage ? (

{tn('reminders.selected', selected.size, { amount: formatCents(selectedTotal) })} diff --git a/apps/dashboard/src/app/dashboard/stock/page.tsx b/apps/dashboard/src/app/dashboard/stock/page.tsx index 8d55f3b..7d89d97 100644 --- a/apps/dashboard/src/app/dashboard/stock/page.tsx +++ b/apps/dashboard/src/app/dashboard/stock/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Fragment, useState } from 'react'; -import { stockApi, type NewStockAdjustment, type Product, type StockAdjustment } from '@/lib/api/client'; +import { Fragment, useEffect, useState } from 'react'; +import { imageUrl, stockApi, type NewStockAdjustment, type Product, type StockAdjustment } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -17,7 +17,7 @@ import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; import { toast } from '@/components/ui/toast'; import { centsToInput, formatCents, parseEuros } from '@/lib/utils'; -import { RefreshCw, Search, Truck, History, Plus, Pencil } from 'lucide-react'; +import { RefreshCw, Search, Truck, History, Plus, Pencil, ImageIcon, Trash2, Upload } from 'lucide-react'; import { useLang } from '@/lib/i18n'; import { useRealtime } from '@/lib/realtime'; import { useSession } from '@/lib/session'; @@ -58,7 +58,8 @@ export default function StockPage() { // Live updates make polling unnecessary; it only runs while the socket is down const { connected: live } = useRealtime(); const { venueId, role } = useSession(); - const isAdmin = role === 'admin'; + // Admins and managers both run the venue + const canManage = role === 'admin' || role === 'manager'; const queryClient = useQueryClient(); const { t, tn, te, relativeTime, dateTime } = useLang(); const [search, setSearch] = useState(''); @@ -88,6 +89,9 @@ export default function StockPage() { const products = allProducts.filter((p) => p.archivedAt === null); const lowCount = products.filter(isLow).length; const categories = [...new Set(allProducts.map((p) => p.category).filter((c): c is string => c !== null))].sort(); + // Two offline terminals can both create "Cola": flagged, so one can be archived + const nameKey = (p: Product) => p.name.trim().toLowerCase(); + const duplicateNames = new Set(products.map(nameKey).filter((key, i, keys) => keys.indexOf(key) !== i)); const query = search.trim().toLowerCase(); const visible = (filter === 'archived' ? allProducts.filter((p) => p.archivedAt !== null) : products).filter((p) => { if (query && !p.name.toLowerCase().includes(query) && !(p.category ?? '').toLowerCase().includes(query)) return false; @@ -121,7 +125,7 @@ export default function StockPage() { {t('stock.delivery')} )} - {isAdmin && ( + {canManage && ( @@ -182,8 +186,14 @@ export default function StockPage() { {group.products.map((product) => ( - {product.name} - {product.archivedAt !== null && {t('stock.archived')}} +

+ + {product.name} + {product.archivedAt !== null && {t('stock.archived')}} + {product.archivedAt === null && duplicateNames.has(nameKey(product)) && ( + {t('products.duplicate')} + )} +
{formatCents(product.priceInCents)} @@ -200,7 +210,7 @@ export default function StockPage() { {product.stock === null ? t('stock.start_tracking') : t('stock.adjust')} )} - {isAdmin && ( + {canManage && ( @@ -423,6 +433,17 @@ function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; v ); } +function ProductThumbnail({ product, size = 36 }: { product: Product; size?: number }) { + return product.imageId ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ +
+ ); +} + /** * Adds a product, or changes or archives one (admin). The terminals get it on their next sync; drinks already on a tab * keep the name and price they were rung up with. @@ -442,6 +463,22 @@ function ProductDialog({ product, categories, venueId, onClose }: { const [threshold, setThreshold] = useState(product?.lowStockThreshold?.toString() ?? ''); const [trackStock, setTrackStock] = useState(true); const [stock, setStock] = useState(''); + // A picked picture is uploaded on save; the server crops it to a square + const [picture, setPicture] = useState(null); + const [removePicture, setRemovePicture] = useState(false); + const [preview, setPreview] = useState(null); + + useEffect(() => { + if (!picture) { + setPreview(null); + return; + } + const url = URL.createObjectURL(picture); + setPreview(url); + return () => URL.revokeObjectURL(url); + }, [picture]); + + const shownPicture = preview ?? (!removePicture && product?.imageId ? imageUrl(product.imageId) : null); const priceInCents = parseEuros(price); const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined; @@ -457,11 +494,14 @@ function ProductDialog({ product, categories, venueId, onClose }: { } const save = useMutation({ - mutationFn: () => { + mutationFn: async () => { const fields = { name: name.trim(), category: category.trim() || null, priceInCents: priceInCents!, lowStockThreshold: thresholdValue! }; - return product === null - ? stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null }) - : stockApi.updateProduct(venueId, product.productId, fields); + const saved = product === null + ? await stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null }) + : await stockApi.updateProduct(venueId, product.productId, fields); + if (picture) return stockApi.uploadImage(venueId, saved.productId, picture); + if (removePicture && product?.imageId) return stockApi.removeImage(venueId, saved.productId); + return saved; }, onSuccess: (saved) => done(t(isNew ? 'products.created' : 'products.saved', { name: saved.name })), onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }), @@ -487,6 +527,52 @@ function ProductDialog({ product, categories, venueId, onClose }: { {t('products.desc')}
+
+ {shownPicture ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ +
+ )} +
+
+ + {shownPicture && ( + + )} +
+

{t('products.picture_hint')}

+
+
setName(e.target.value)} autoFocus /> diff --git a/apps/dashboard/src/app/dashboard/tabs/page.tsx b/apps/dashboard/src/app/dashboard/tabs/page.tsx index 3e8561f..cecac57 100644 --- a/apps/dashboard/src/app/dashboard/tabs/page.tsx +++ b/apps/dashboard/src/app/dashboard/tabs/page.tsx @@ -35,7 +35,8 @@ export default function TabsPage() { // Live updates make polling unnecessary; it only runs while the socket is down const { connected: live } = useRealtime(); const { venueId, role } = useSession(); - const isAdmin = role === 'admin'; + // Admins and managers both run the venue + const canManage = role === 'admin' || role === 'manager'; const queryClient = useQueryClient(); const [closeTab, setCloseTab] = useState(null); const [paymentMethod, setPaymentMethod] = useState(''); @@ -157,7 +158,7 @@ export default function TabsPage() {
{t('planned.title')} - {isAdmin && ( + {canManage && ( @@ -165,7 +166,7 @@ export default function TabsPage() {
- {isAdmin && ( + {canManage && (
{pt.customerName} {pt.email && } - {isAdmin && pt.email && ( + {canManage && pt.email && ( )} - {isAdmin && ( + {canManage && ( @@ -283,7 +284,7 @@ export default function TabsPage() {
{tab.email} - {isAdmin && tab.plannedTabId && ( + {canManage && tab.plannedTabId && (