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

This commit is contained in:
2026-10-21 12:45:19 +02:00
parent ec77dd0e27
commit 5c604e0851
38 changed files with 1241 additions and 77 deletions
+2
View File
@@ -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",
@@ -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';
+23 -1
View File
@@ -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[]
}
+1 -1
View File
@@ -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!');
+2
View File
@@ -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);
});
});
+3 -1
View File
@@ -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);
}
}
+19 -1
View File
@@ -8,9 +8,14 @@ import {
HttpCode,
HttpStatus,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { SkipThrottle, Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes } from '@nestjs/swagger';
import { ImagesService } from '../images/images.service';
import { MAX_UPLOAD_BYTES } from '../images/image-processing';
import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -27,6 +32,7 @@ export class DevicesController {
constructor(
private devicesService: DevicesService,
private authService: AuthService,
private images: ImagesService,
) {}
@Post('register')
@@ -48,6 +54,18 @@ export class DevicesController {
return this.devicesService.sync(req.user, dto);
}
// A picture taken on a terminal. Only stored: the terminal links it to its product with a product_updated event,
// so an offline edit and its picture follow the same rules as every other change
@Post(':deviceId/images')
@SkipThrottle()
@UseGuards(DeviceAuthGuard)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } }))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload a product picture from a terminal' })
async uploadImage(@UploadedFile() file: Express.Multer.File | undefined) {
return { imageId: await this.images.store(file?.buffer) };
}
@Post(':deviceId/commands/:commandId/ack')
@SkipThrottle()
@UseGuards(DeviceAuthGuard)
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DevicesController } from './devices.controller';
import { DevicesService } from './devices.service';
import { ImagesModule } from '../images/images.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, ImagesModule],
controllers: [DevicesController],
providers: [DevicesService],
exports: [DevicesService],
@@ -44,6 +44,8 @@ const events = {
amountInCents: quantity * 250, paymentMethod: 'payconiq',
items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }],
}),
product: (type: string, productId: string, product: Record<string, unknown> | undefined, minutes = 0) => (
{ id: uuidv4(), type, productId, ...(product ? { product } : {}), occurredAt: at(minutes) }),
stockSet: (productId: string, quantity: number | null, minutes = 0) => (
{ id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }),
};
@@ -531,3 +533,134 @@ describe('DevicesService.sync: website product changes reach the tablets', () =>
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', name: 'Coca-Cola' })]);
});
});
describe('DevicesService.sync: products created and changed on a tablet', () => {
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
function setupWithWebsite() {
const venue = setup();
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
return { ...venue, stock };
}
const catalogEntry = (result: SyncResult, id: string) => result.catalog.find((p) => p.id === id);
it('creates a product made offline, and sells and counts it in the same sync', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, {
events: [
events.product('product_created', 'iced', { name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3 }, 1),
events.stockSet('iced', 12, 2),
events.opened('t1', 'Alice', 3),
events.line('line_added', 't1', 2, { minutes: 4, productId: 'iced', productName: 'Ice Coffee', price: 350 }),
],
});
const result = await sync(tabletB);
expect(catalogEntry(result, 'iced')).toEqual(
{ id: 'iced', name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3, archived: false, imageId: null });
expect(result.stock.find((s) => s.productId === 'iced')?.stock).toBe(10);
});
it('keeps both a website price change and an offline rename of the same product', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
// Renamed offline before the price change, synced after it
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 5)] });
expect(catalogEntry(result, 'cola')).toMatchObject({ name: 'Coca-Cola', priceInCents: 270 });
expect(prisma.product.rows[0].fieldUpdatedAt).toMatchObject({ name: at(5) });
});
it('lets the latest change of a field win, also when the older one arrives last', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await sync(tabletB, { events: [events.product('product_updated', 'cola', { priceInCents: 300 }, 20)] });
// Tablet A was offline: its older price change arrives later and loses
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 10)] });
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(300);
});
it('a website change made after a tablet change wins', async () => {
const { sync, tabletA, stock } = setupWithWebsite();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 1)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 260 }, admin);
const result = await sync(tabletA);
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(260);
});
it('archives and restores a product from a tablet', async () => {
const { sync, tabletA } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
const archived = await sync(tabletA, { events: [events.product('product_archived', 'cola', undefined, 1)] });
expect(catalogEntry(archived, 'cola')!.archived).toBe(true);
const restored = await sync(tabletA, { events: [events.product('product_restored', 'cola', undefined, 2)] });
expect(catalogEntry(restored, 'cola')!.archived).toBe(false);
});
it('only links a picture that was uploaded', async () => {
const { sync, tabletA, prisma } = setup();
prisma.productImage.rows.push({ id: 'a'.repeat(64), contentType: 'image/webp', width: 512, height: 512, bytes: Buffer.from('x') });
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
const unknown = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'b'.repeat(64) }, 1)] });
expect(catalogEntry(unknown, 'cola')!.imageId).toBeNull();
const known = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'a'.repeat(64) }, 2)] });
expect(catalogEntry(known, 'cola')!.imageId).toBe('a'.repeat(64));
const removed = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: null }, 3)] });
expect(catalogEntry(removed, 'cola')!.imageId).toBeNull();
});
it('accepts two products with the same name from two offline tablets', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola-a', { name: 'Cola', priceInCents: 250 }, 0)] });
const result = await sync(tabletB, { events: [events.product('product_created', 'cola-b', { name: 'cola', priceInCents: 250 }, 0)] });
expect(result.catalog.map((p) => p.id).sort()).toEqual(['cola-a', 'cola-b']);
});
it('acknowledges a change to a product the venue does not have, without creating it', async () => {
const { sync, tabletA } = setup();
const change = events.product('product_updated', 'gone', { name: 'Gone' }, 0);
const result = await sync(tabletA, { events: [change] });
expect(result.acknowledgedEventIds).toEqual([change.id]);
expect(result.catalog).toEqual([]);
});
it('rejects a new product without a price', async () => {
const { sync, tabletA } = setup();
const incomplete = events.product('product_created', 'x', { name: 'X' }, 0);
const result = await sync(tabletA, { events: [incomplete] });
expect(result.rejectedEvents).toEqual([{ id: incomplete.id, reason: 'a new product needs a name and a price' }]);
});
it('applies a product event once, also when it is sent again', async () => {
const { sync, tabletA, tabletB } = setup();
const created = events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0);
const renamed = events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 1);
await sync(tabletA, { events: [created, renamed] });
await sync(tabletB, { events: [events.product('product_updated', 'cola', { name: 'Cola Classic' }, 2)] });
// The old rename again, with its id: already applied, so it must not undo the newer name
const result = await sync(tabletA, { events: [renamed] });
expect(catalogEntry(result, 'cola')!.name).toBe('Cola Classic');
});
});
+14 -2
View File
@@ -19,6 +19,8 @@ import { VenueLedger } from '../tabs/venue-ledger';
import { seedTabs, tabJson } from '../tabs/tab-state';
import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
import { applyPendingAdjustments } from '../stock/stock-adjustments';
import { applyProductEvent } from '../stock/product-events';
import { isProductEvent } from './dto/tab-event.dto';
@Injectable()
export class DevicesService {
@@ -59,7 +61,7 @@ export class DevicesService {
const ledger = await VenueLedger.open(tx, venueId);
// 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
const productsChanged = reported && !ledger.seeded
let productsChanged = reported && !ledger.seeded
? await this.saveProducts(tx, ledger, reported.products, now)
: false;
@@ -83,6 +85,11 @@ export class DevicesService {
} else {
for (const event of fresh) {
const occurredAt = eventTime(event, now);
// Products first: a sale or count of a product created offline arrives in the same request
if (isProductEvent(event)) {
if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true;
continue;
}
const settlement = ledger.tabs.apply(event, occurredAt);
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
ledger.stock.apply(event, occurredAt);
@@ -117,7 +124,10 @@ export class DevicesService {
const catalog = await tx.product.findMany({
where: { venueId },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
select: { productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true },
select: {
productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true,
imageId: true,
},
});
return {
@@ -198,6 +208,8 @@ export class DevicesService {
priceInCents: p.priceInCents,
lowStockThreshold: p.lowStockThreshold,
archived: p.archivedAt !== null,
// The picture is at /images/<imageId>; null: none
imageId: p.imageId,
})),
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
+50 -3
View File
@@ -1,5 +1,5 @@
import {
IsString, IsIn, IsInt, Min, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
IsString, IsIn, IsInt, Min, Max, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
ArrayMaxSize, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
@@ -8,7 +8,10 @@ import { OpenTabItemDto } from './sync.dto';
export const TAB_EVENT_TYPES = [
'tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set',
'product_created', 'product_updated', 'product_archived', 'product_restored',
] as const;
export const PRODUCT_EVENT_TYPES = ['product_created', 'product_updated', 'product_archived', 'product_restored'] as const;
export type TabEventType = (typeof TAB_EVENT_TYPES)[number];
export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const;
@@ -16,7 +19,45 @@ export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending'
const isLine = (event: TabEventDto) => event.type === 'line_added' || event.type === 'line_removed';
const isSettlement = (event: TabEventDto) => event.type === 'settled';
const isStockSet = (event: TabEventDto) => event.type === 'stock_set';
const isAboutTab = (event: TabEventDto) => !isStockSet(event);
export const isProductEvent = (event: TabEventDto) => (PRODUCT_EVENT_TYPES as readonly string[]).includes(event.type);
const isAboutTab = (event: TabEventDto) => !isStockSet(event) && !isProductEvent(event);
const carriesProduct = (event: TabEventDto) => event.type === 'product_created' || event.type === 'product_updated';
/** A product as created or changed on a terminal: only the fields that changed are sent. */
export class ProductEventFieldsDto {
@ApiPropertyOptional({ example: 'Cola' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiPropertyOptional({ example: 250 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents?: number;
@ApiPropertyOptional({ example: 12, nullable: true })
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold?: number | null;
@ApiPropertyOptional({ description: 'A picture uploaded to /devices/:deviceId/images first; null removes it', nullable: true })
@IsOptional()
@IsString()
@MaxLength(64)
imageId?: string | null;
}
export class SettledItemDto extends OpenTabItemDto {
@ApiPropertyOptional({ example: '12', description: 'Missing in events from older app versions' })
@@ -64,7 +105,7 @@ export class TabEventDto {
lineId?: string;
@ApiPropertyOptional({ example: '12', description: 'line_added / line_removed (missing from older app versions), stock_set' })
@ValidateIf((event: TabEventDto) => isStockSet(event) || event.productId !== undefined)
@ValidateIf((event: TabEventDto) => isStockSet(event) || isProductEvent(event) || event.productId !== undefined)
@IsString()
@IsNotEmpty()
@MaxLength(100)
@@ -109,6 +150,12 @@ export class TabEventDto {
@IsUUID()
commandId?: string;
@ApiPropertyOptional({ type: ProductEventFieldsDto, description: 'product_created / product_updated' })
@ValidateIf(carriesProduct)
@ValidateNested()
@Type(() => ProductEventFieldsDto)
product?: ProductEventFieldsDto;
@ApiPropertyOptional({ type: [SettledItemDto], description: 'settled: the lines that were settled' })
@IsOptional()
@IsArray()
+12 -1
View File
@@ -1,7 +1,7 @@
import { plainToInstance } from 'class-transformer';
import { validateSync, ValidationError } from 'class-validator';
import { Prisma } from '@prisma/client';
import { TabEventDto } from './dto/tab-event.dto';
import { isProductEvent, TabEventDto } from './dto/tab-event.dto';
// A terminal clock that runs ahead must not put sales in the future
const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000;
@@ -30,6 +30,10 @@ export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejec
rejected.push({ id, reason: 'quantity must not be less than 1' });
continue;
}
if (event.type === 'product_created' && (!event.product?.name || event.product.priceInCents === undefined)) {
rejected.push({ id, reason: 'a new product needs a name and a price' });
continue;
}
accepted.push(event);
}
@@ -57,6 +61,13 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
if (event.type === 'stock_set') {
return { ...base, productId: event.productId, stockSetTo: event.quantity ?? null };
}
if (isProductEvent(event)) {
return {
...base,
productId: event.productId,
productData: event.product ? ({ ...event.product } as Prisma.InputJsonValue) : Prisma.JsonNull,
};
}
if (event.type === 'tab_opened' || event.type === 'tab_renamed' || event.type === 'tab_deleted') {
return base;
}
@@ -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);
});
});
+61
View File
@@ -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;
}
}
+31
View File
@@ -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));
}
}
+10
View File
@@ -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 {}
+40
View File
@@ -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' })
+70
View File
@@ -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;
}
+51
View File
@@ -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()]));
}
+32 -5
View File
@@ -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);
}
}
+2
View File
@@ -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],
})
+32
View File
@@ -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']);
});
});
+60 -11
View File
@@ -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);
}
return tx.product.update({
where: { venueId_productId: { venueId, productId } },
data: {
name,
// 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 } : {}),
archivedAt: archived ? (existing.archivedAt ?? new Date()) : null,
reportedAt: new Date(),
},
...(dto.archived !== undefined ? { archived: dto.archived } : {}),
}, maxDate(now, existing.fieldUpdatedAt), existing);
return tx.product.update({
where: { venueId_productId: { venueId, productId } },
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,
};
}
+4 -1
View File
@@ -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() })),
+2 -2
View File
@@ -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;
}
@@ -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"
/>
</div>
{isAdmin && (
{canManage && (
<div className="flex gap-2 sm:ml-auto">
<Button variant="outline" size="sm" onClick={() => setPicked(null)}>
{t('reminders.select_eligible')}
@@ -224,7 +225,7 @@ export default function RemindersPage() {
<Table>
<TableHeader>
<TableRow>
{isAdmin && (
{canManage && (
<TableHead className="w-10">
<input
type="checkbox"
@@ -250,10 +251,10 @@ export default function RemindersPage() {
<TableRow
key={c.tabId}
data-state={isSelected ? 'selected' : undefined}
className={isAdmin && c.email ? 'cursor-pointer' : undefined}
onClick={isAdmin && c.email ? () => toggle(c.tabId) : undefined}
className={canManage && c.email ? 'cursor-pointer' : undefined}
onClick={canManage && c.email ? () => toggle(c.tabId) : undefined}
>
{isAdmin && (
{canManage && (
<TableCell onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
@@ -290,7 +291,7 @@ export default function RemindersPage() {
})}
{visible.length === 0 && (
<TableRow>
<TableCell colSpan={isAdmin ? 7 : 6} className="text-center text-sm text-muted-foreground py-6">
<TableCell colSpan={canManage ? 7 : 6} className="text-center text-sm text-muted-foreground py-6">
{t('reminders.no_matches')}
</TableCell>
</TableRow>
@@ -299,7 +300,7 @@ export default function RemindersPage() {
</Table>
</div>
{isAdmin ? (
{canManage ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-muted-foreground">
{tn('reminders.selected', selected.size, { amount: formatCents(selectedTotal) })}
+98 -12
View File
@@ -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() {
<Truck className="h-4 w-4 mr-2" />{t('stock.delivery')}
</Button>
)}
{isAdmin && (
{canManage && (
<Button size="sm" onClick={() => setEditing('new')}>
<Plus className="h-4 w-4 mr-2" />{t('products.new')}
</Button>
@@ -182,8 +186,14 @@ export default function StockPage() {
{group.products.map((product) => (
<TableRow key={product.productId}>
<TableCell className="font-medium">
{product.name}
{product.archivedAt !== null && <Badge variant="secondary" className="ml-2">{t('stock.archived')}</Badge>}
<div className="flex items-center gap-3">
<ProductThumbnail product={product} />
<span>{product.name}</span>
{product.archivedAt !== null && <Badge variant="secondary">{t('stock.archived')}</Badge>}
{product.archivedAt === null && duplicateNames.has(nameKey(product)) && (
<Badge variant="warning" title={t('products.duplicate_desc')}>{t('products.duplicate')}</Badge>
)}
</div>
</TableCell>
<TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell>
<TableCell><StockLevel product={product} /></TableCell>
@@ -200,7 +210,7 @@ export default function StockPage() {
{product.stock === null ? t('stock.start_tracking') : t('stock.adjust')}
</Button>
)}
{isAdmin && (
{canManage && (
<Button size="sm" variant="ghost" className="ml-1" onClick={() => setEditing(product)} aria-label={t('products.edit_title', { name: product.name })}>
<Pencil className="h-4 w-4" />
</Button>
@@ -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
<img src={imageUrl(product.imageId)} alt="" width={size} height={size} className="rounded-md bg-muted object-contain shrink-0" />
) : (
<div style={{ width: size, height: size }} className="rounded-md bg-muted flex items-center justify-center shrink-0">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
/**
* 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<File | null>(null);
const [removePicture, setRemovePicture] = useState(false);
const [preview, setPreview] = useState<string | null>(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 }: {
<DialogDescription>{t('products.desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex items-center gap-4">
{shownPicture ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={shownPicture} alt="" className="h-24 w-24 rounded-lg bg-muted object-contain" />
) : (
<div className="h-24 w-24 rounded-lg bg-muted flex items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)}
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<label className="cursor-pointer">
<Upload className="h-4 w-4 mr-2" />
{shownPicture ? t('products.change_picture') : t('products.choose_picture')}
<input
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
e.target.value = '';
if (file) {
setPicture(file);
setRemovePicture(false);
}
}}
/>
</label>
</Button>
{shownPicture && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setPicture(null);
setRemovePicture(true);
}}
>
<Trash2 className="h-4 w-4 mr-2" />{t('products.remove_picture')}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">{t('products.picture_hint')}</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="product-name">{t('products.name')}</Label>
<Input id="product-name" value={name} maxLength={200} onChange={(e) => setName(e.target.value)} autoFocus />
@@ -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<Tab | null>(null);
const [paymentMethod, setPaymentMethod] = useState('');
@@ -157,7 +158,7 @@ export default function TabsPage() {
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> {t('planned.title')}</CardTitle>
{isAdmin && (
{canManage && (
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4 mr-2" />{t('import.button')}
</Button>
@@ -165,7 +166,7 @@ export default function TabsPage() {
</div>
</CardHeader>
<CardContent>
{isAdmin && (
{canManage && (
<div className="flex gap-2 mb-3 flex-wrap">
<Input
placeholder={t('planned.name_placeholder')}
@@ -195,7 +196,7 @@ export default function TabsPage() {
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
{pt.customerName}
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
{isAdmin && pt.email && (
{canManage && pt.email && (
<button
onClick={() => sendTestMutation.mutate(pt.id)}
disabled={sendTestMutation.isPending}
@@ -206,7 +207,7 @@ export default function TabsPage() {
<Send className="h-3 w-3" />
</button>
)}
{isAdmin && (
{canManage && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5" aria-label={t('planned.remove', { name: pt.customerName })}>
<X className="h-3 w-3" />
</button>
@@ -283,7 +284,7 @@ export default function TabsPage() {
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
<span className="truncate max-w-[200px]">{tab.email}</span>
{isAdmin && tab.plannedTabId && (
{canManage && tab.plannedTabId && (
<button
onClick={() => sendTestMutation.mutate(tab.plannedTabId!)}
disabled={sendTestMutation.isPending}
@@ -23,7 +23,7 @@ export default function UsersPage() {
const queryClient = useQueryClient();
const { venueId, role } = useSession();
const [addDialog, setAddDialog] = useState(false);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
const [submitting, setSubmitting] = useState(false);
const { t, tn, te, relativeTime } = useLang();
@@ -41,7 +41,7 @@ export default function UsersPage() {
await usersApi.create(venueId, form);
toast({ title: t('users.created_toast'), description: t('users.created_toast_desc', { email: form.email, role: t(`users.role.${form.role}`) }), variant: 'success' });
setAddDialog(false);
setForm({ email: '', password: '', name: '', role: 'viewer' });
setForm({ email: '', password: '', name: '', role: 'manager' });
queryClient.invalidateQueries({ queryKey: ['users'] });
} catch (err) {
toast({ title: t('users.create_failed'), description: te(err), variant: 'destructive' });
@@ -123,7 +123,7 @@ export default function UsersPage() {
<Select value={form.role} onValueChange={v => setForm({ ...form, role: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="viewer">{t('users.role_viewer')}</SelectItem>
<SelectItem value="manager">{t('users.role_manager')}</SelectItem>
<SelectItem value="admin">{t('users.role_admin')}</SelectItem>
</SelectContent>
</Select>
+19 -2
View File
@@ -5,7 +5,8 @@ export interface ApiError {
message: string;
}
export type Role = 'admin' | 'viewer';
/** admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders) */
export type Role = 'admin' | 'manager';
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
@@ -125,6 +126,13 @@ export interface Product {
reportedAt: string;
/** Archived (deleted): hidden on the terminals */
archivedAt: string | null;
/** The picture, at imageUrl(imageId); null: none */
imageId: string | null;
}
/** Pictures never change at an address, so browsers cache them for good. */
export function imageUrl(imageId: string): string {
return `${API_URL}/images/${imageId}`;
}
export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null };
@@ -199,7 +207,8 @@ export async function api<T = unknown>(
): Promise<T> {
const accessToken = getCookie('access_token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
// A file upload sets its own multipart content type
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(options.headers as Record<string, string>),
};
@@ -358,6 +367,14 @@ export const stockApi = {
method: 'POST',
body: JSON.stringify(product),
}),
/** Cropped to a square and stored on the server; the terminals download it on their next sync */
uploadImage: (venueId: string, productId: string, file: Blob) => {
const body = new FormData();
body.append('file', file);
return api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}/image`, { method: 'POST', body });
},
removeImage: (venueId: string, productId: string) =>
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}/image`, { method: 'DELETE' }),
/** Only the fields that are sent change; archived: true deletes (hides) the product */
updateProduct: (venueId: string, productId: string, changes: Partial<ProductFields> & { archived?: boolean }) =>
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, {
+19 -7
View File
@@ -151,9 +151,9 @@ const translations: Record<Lang, Record<string, string>> = {
'users.name_placeholder': 'Bartender name',
'users.password': 'Password',
'users.password_placeholder': 'At least 6 characters',
'users.role_viewer': 'Viewer (can close tabs)',
'users.role_admin': 'Admin (full access)',
'users.role.viewer': 'Viewer',
'users.role_manager': 'Manager (runs the venue: products, stock, tabs, reminders)',
'users.role_admin': 'Admin (also users and terminals)',
'users.role.manager': 'Manager',
'users.role.admin': 'Admin',
'users.creating': 'Creating...',
'users.create': 'Create User',
@@ -296,6 +296,12 @@ const translations: Record<Lang, Record<string, string>> = {
'products.edit_title': 'Edit {name}',
'products.desc': 'The terminals get it on their next sync. Drinks already on a tab keep their name and price.',
'products.name': 'Name',
'products.choose_picture': 'Choose picture',
'products.change_picture': 'Change picture',
'products.remove_picture': 'Remove',
'products.picture_hint': 'Made square: a glass is cropped, a bottle is shown whole. The terminals keep it for offline use.',
'products.duplicate': 'Duplicate',
'products.duplicate_desc': 'Another product has the same name, for example created on two terminals at once. Archive the one you do not need.',
'products.category': 'Category',
'products.category_placeholder': 'e.g. Frisdrank',
'products.price': 'Price (€)',
@@ -506,10 +512,10 @@ const translations: Record<Lang, Record<string, string>> = {
'users.name_placeholder': 'Naam van de medewerker',
'users.password': 'Wachtwoord',
'users.password_placeholder': 'Minstens 6 tekens',
'users.role_viewer': 'Medewerker (kan poefs sluiten)',
'users.role_admin': 'Beheerder (volledige toegang)',
'users.role.viewer': 'Medewerker',
'users.role.admin': 'Beheerder',
'users.role_manager': 'Beheerder (runt de zaak: producten, voorraad, poefs, herinneringen)',
'users.role_admin': 'Admin (ook gebruikers en kassa\'s)',
'users.role.manager': 'Beheerder',
'users.role.admin': 'Admin',
'users.creating': 'Bezig met aanmaken...',
'users.create': 'Gebruiker aanmaken',
'users.created_toast': 'Gebruiker aangemaakt',
@@ -651,6 +657,12 @@ const translations: Record<Lang, Record<string, string>> = {
'products.edit_title': '{name} bewerken',
'products.desc': "De kassa's krijgen het bij hun volgende synchronisatie. Wat al op een poef staat, houdt zijn naam en prijs.",
'products.name': 'Naam',
'products.choose_picture': 'Foto kiezen',
'products.change_picture': 'Andere foto',
'products.remove_picture': 'Verwijderen',
'products.picture_hint': "Wordt vierkant: een glas wordt bijgesneden, een fles blijft heel. De kassa's bewaren ze om offline te tonen.",
'products.duplicate': 'Dubbel',
'products.duplicate_desc': 'Een ander product heeft dezelfde naam, bijvoorbeeld tegelijk aangemaakt op twee kassa\'s. Archiveer het product dat je niet nodig hebt.',
'products.category': 'Categorie',
'products.category_placeholder': 'bv. Frisdrank',
'products.price': 'Prijs (€)',
+3 -1
View File
@@ -18,7 +18,9 @@ export function readSession(): Session | null {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
if (!payload.venueId || !payload.role) return null;
return { userId: payload.sub, email: payload.email, role: payload.role, venueId: payload.venueId };
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
const role = payload.role === 'viewer' ? 'manager' : payload.role;
return { userId: payload.sub, email: payload.email, role, venueId: payload.venueId };
} catch {
return null;
}
+1 -1
View File
@@ -6,7 +6,7 @@ export type CommandType = 'close_tab';
export type PaymentMethod = 'cash' | 'payconiq' | 'payment_pending';
export type ClientUserRole = 'admin' | 'viewer';
export type ClientUserRole = 'admin' | 'manager';
export type ActorType = 'client_user' | 'device' | 'system';
+272
View File
@@ -80,6 +80,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
sharp:
specifier: 0.33.5
version: 0.33.5
socket.io:
specifier: ^4.8.1
version: 4.8.1(supports-color@8.1.1)
@@ -111,6 +114,9 @@ importers:
'@types/jest':
specifier: ^29.5.11
version: 29.5.14
'@types/multer':
specifier: 1.4.12
version: 1.4.12
'@types/node':
specifier: ^20.11.0
version: 20.19.43
@@ -442,6 +448,9 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
@@ -613,6 +622,123 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.33.5':
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.0.4':
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.0.4':
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.0.4':
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-ia32@0.33.5':
resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.33.5':
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -1521,6 +1647,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@1.4.12':
resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==}
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -1990,10 +2119,17 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -2571,6 +2707,9 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -3527,6 +3666,10 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -3558,6 +3701,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -4353,6 +4499,11 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.28.2':
optional: true
@@ -4448,6 +4599,81 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
'@img/sharp-darwin-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
optional: true
'@img/sharp-darwin-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.0.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm@1.0.5':
optional: true
'@img/sharp-libvips-linux-s390x@1.0.4':
optional: true
'@img/sharp-libvips-linux-x64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
optional: true
'@img/sharp-linux-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.0.4
optional: true
'@img/sharp-linux-arm@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5
optional: true
'@img/sharp-linux-s390x@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.0.4
optional: true
'@img/sharp-linux-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4
optional: true
'@img/sharp-linuxmusl-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
optional: true
'@img/sharp-linuxmusl-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
optional: true
'@img/sharp-wasm32@0.33.5':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-win32-ia32@0.33.5':
optional: true
'@img/sharp-win32-x64@0.33.5':
optional: true
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -5516,6 +5742,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/multer@1.4.12':
dependencies:
'@types/express': 4.17.25
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
@@ -6047,8 +6277,18 @@ snapshots:
color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
color-support@1.1.3: {}
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@@ -6723,6 +6963,8 @@ snapshots:
is-arrayish@0.2.1: {}
is-arrayish@0.3.4: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -7810,6 +8052,32 @@ snapshots:
setprototypeof@1.2.0: {}
sharp@0.33.5:
dependencies:
color: 4.2.3
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5
'@img/sharp-libvips-darwin-arm64': 1.0.4
'@img/sharp-libvips-darwin-x64': 1.0.4
'@img/sharp-libvips-linux-arm': 1.0.5
'@img/sharp-libvips-linux-arm64': 1.0.4
'@img/sharp-libvips-linux-s390x': 1.0.4
'@img/sharp-libvips-linux-x64': 1.0.4
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
'@img/sharp-linux-arm': 0.33.5
'@img/sharp-linux-arm64': 0.33.5
'@img/sharp-linux-s390x': 0.33.5
'@img/sharp-linux-x64': 0.33.5
'@img/sharp-linuxmusl-arm64': 0.33.5
'@img/sharp-linuxmusl-x64': 0.33.5
'@img/sharp-wasm32': 0.33.5
'@img/sharp-win32-ia32': 0.33.5
'@img/sharp-win32-x64': 0.33.5
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -7848,6 +8116,10 @@ snapshots:
signal-exit@4.1.0: {}
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
sisteransi@1.0.5: {}
slash@3.0.0: {}
+1
View File
@@ -8,3 +8,4 @@ allowBuilds:
bcrypt: true
esbuild: true
prisma: true
sharp: set this to true or false