This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ProcessedImage> {
|
||||
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<Buffer> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 <img> 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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<ProductChanges> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Record<ProductField, string>>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>)[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()]));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>)
|
||||
.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() })),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user