This commit is contained in:
@@ -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';
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`, {
|
||||
|
||||
@@ -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 (€)',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Generated
+272
@@ -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: {}
|
||||
|
||||
@@ -8,3 +8,4 @@ allowBuilds:
|
||||
bcrypt: true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
sharp: set this to true or false
|
||||
|
||||
Reference in New Issue
Block a user