feat: add stuff
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 14:45:22 +02:00
parent ee06af8395
commit 1e4438ec5a
24 changed files with 1734 additions and 14 deletions
@@ -0,0 +1,45 @@
-- CreateTable
CREATE TABLE "Product" (
"venueId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"priceInCents" INTEGER NOT NULL,
"stock" INTEGER,
"lowStockThreshold" INTEGER,
"reportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("venueId","productId")
);
-- CreateTable
CREATE TABLE "StockAdjustment" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"type" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"note" TEXT,
"requestedBy" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"stockAfter" INTEGER,
"result" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "StockAdjustment_venueId_status_createdAt_idx" ON "StockAdjustment"("venueId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "StockAdjustment_venueId_productId_createdAt_idx" ON "StockAdjustment"("venueId", "productId", "createdAt");
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+58 -9
View File
@@ -13,15 +13,17 @@ model Venue {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices Device[]
clientUsers ClientUser[]
tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
tabReminders TabReminder[]
tabEvents TabEvent[]
devices Device[]
clientUsers ClientUser[]
tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
tabReminders TabReminder[]
tabEvents TabEvent[]
products Product[]
stockAdjustments StockAdjustment[]
}
model Device {
@@ -214,3 +216,50 @@ model TabEvent {
@@index([venueId, occurredAt])
@@index([venueId, type, occurredAt])
}
// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the
// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments.
model Product {
venueId String
// Generated by the terminal
productId String
name String
category String?
priceInCents Int
// Null when the terminal doesn't track stock for this product. Can be negative: a sale is never blocked.
stock Int?
// Dashboard-only setting, never sent to or overwritten by the terminal
lowStockThreshold Int?
reportedAt DateTime
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, productId])
}
// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal
// reports it applied or rejected.
model StockAdjustment {
id String @id @default(uuid())
venueId String
productId String
productName String
// add (signed: a delivery, or breakage when negative) | set (a stock count)
type String
quantity Int
note String?
requestedBy String
requestedById String
// pending | applied | rejected | cancelled
status String @default("pending")
// The terminal's stock right after applying it
stockAfter Int?
result String?
createdAt DateTime @default(now())
processedAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId, status, createdAt])
@@index([venueId, productId, createdAt])
}
+2
View File
@@ -12,6 +12,7 @@ import { AuditModule } from './audit/audit.module';
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 { RealtimeModule } from './realtime/realtime.module';
import { EmailModule } from './email/email.module';
import { HealthController } from './health.controller';
@@ -32,6 +33,7 @@ import { HealthController } from './health.controller';
JobsModule,
PlannedTabsModule,
RemindersModule,
StockModule,
],
controllers: [HealthController],
providers: [
+91 -1
View File
@@ -3,13 +3,20 @@ import { DevicesService } from './devices.service';
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
type StoredProduct = { productId: string; name: string; category: string | null; priceInCents: number; stock: number | null };
function createService({
previous = [] as Snapshot[], delivered = 0, expired = 0, products = [] as StoredProduct[], pendingStock = [] as unknown[],
recordedResults = 1,
} = {}) {
const tx = {
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() },
tabEvent: { createMany: jest.fn() },
product: { deleteMany: jest.fn(), updateMany: jest.fn(), upsert: jest.fn() },
stockAdjustment: { updateMany: jest.fn().mockResolvedValue({ count: recordedResults }) },
};
const prisma = {
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
@@ -17,6 +24,8 @@ function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0
$transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) },
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) },
product: { findMany: jest.fn().mockResolvedValue(products) },
stockAdjustment: { findMany: jest.fn().mockResolvedValue(pendingStock) },
};
const audit = { log: jest.fn() };
const realtime = { notify: jest.fn() };
@@ -201,3 +210,84 @@ describe('DevicesService.sync tab events', () => {
expect(result.acknowledgedEventIds).toEqual([]);
});
});
describe('DevicesService.sync stock', () => {
const cola = { id: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
const storedCola = { productId: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
it('leaves products alone when the terminal does not report them', async () => {
const { service, prisma, tx } = createService({ products: [storedCola] });
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.product.findMany).not.toHaveBeenCalled();
expect(tx.product.deleteMany).not.toHaveBeenCalled();
});
it('only writes changed products, removes missing ones and signals "stock"', async () => {
const storedWater = { productId: 'p2', name: 'Water', category: null, priceInCents: 200, stock: null };
const { service, tx, realtime } = createService({ products: [storedCola, storedWater] });
await service.sync(device, {
deviceId: 'd1',
openTabs: [],
products: [{ ...cola, stock: 46 }, { id: 'p3', name: ' Bier ', priceInCents: 300, stock: null }],
});
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1', 'p3'] } } });
expect(tx.product.upsert).toHaveBeenCalledTimes(2);
expect(tx.product.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ productId: 'p3', name: 'Bier', category: null, stock: null }),
}));
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('does not signal anything for an unchanged catalog', async () => {
const { service, tx, realtime } = createService({ products: [storedCola], recordedResults: 0 });
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola] });
expect(tx.product.upsert).not.toHaveBeenCalled();
expect(realtime.notify).not.toHaveBeenCalled();
});
it('skips an invalid product without failing the sync', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola, { id: 'p9', name: 'Broken', priceInCents: -1, stock: 3 }] });
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1'] } } });
});
it('records stock adjustment results and acknowledges them', async () => {
const { service, tx, realtime } = createService();
const id = '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c';
const result = await service.sync(device, {
deviceId: 'd1',
openTabs: [],
stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }, { id: 'nope', status: 'applied' }],
});
expect(tx.stockAdjustment.updateMany).toHaveBeenCalledWith({
where: { id, venueId: 'v1', status: { in: ['pending', 'cancelled'] } },
data: { status: 'applied', stockAfter: 72, result: null, processedAt: expect.any(Date) },
});
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
expect(result.rejectedStockAdjustmentResults.map((r) => r.id)).toEqual(['nope']);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('sends the pending stock adjustments of the venue', async () => {
const pending = [{ id: 'a1', productId: 'p1', type: 'add', quantity: 24, note: null, requestedBy: 'Bram', createdAt: new Date() }];
const { service, prisma } = createService({ pendingStock: pending });
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.stockAdjustment.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { venueId: 'v1', status: 'pending' },
orderBy: { createdAt: 'asc' },
}));
expect(result.stockAdjustments).toEqual(pending);
});
});
+84
View File
@@ -15,6 +15,7 @@ import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
import { customerNameKey } from '../planned-tabs/customer-name';
import { AckCommandDto } from './dto/ack-command.dto';
import { parseTabEvents, tabEventRow } from './tab-events';
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
@Injectable()
export class DevicesService {
@@ -39,6 +40,15 @@ export class DevicesService {
for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
}
// Omitted by app versions without stock: the products are then left as they are
const reported = dto.products ? parseProducts(dto.products) : null;
for (const { id, reason } of reported?.rejected ?? []) {
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
}
const stockResults = parseStockResults(dto.stockAdjustmentResults ?? []);
for (const { id, reason } of stockResults.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
}
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
@@ -67,6 +77,22 @@ export class DevicesService {
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
let plannedDelivered = 0;
// Products belong to the venue rather than the device, so they survive pairing a new terminal
const previousProducts = reported
? await this.prisma.product.findMany({
where: { venueId: device.venueId },
select: { productId: true, name: true, category: true, priceInCents: true, stock: true },
})
: [];
const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p]));
const changedProducts = (reported?.products ?? []).filter((p) => {
const stored = previousProductsById.get(p.id);
return !stored || !sameProduct(stored, p);
});
const productsChanged = reported !== null
&& (previousProducts.length !== reported.products.length || changedProducts.length > 0);
let stockResultsRecorded = 0;
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
@@ -148,6 +174,43 @@ export class DevicesService {
}));
}
if (reported) {
const reportedIds = reported.products.map((p) => p.id);
await tx.product.deleteMany({ where: { venueId: device.venueId, productId: { notIn: reportedIds } } });
await tx.product.updateMany({
where: { venueId: device.venueId, productId: { in: reportedIds } },
data: { reportedAt: now },
});
for (const product of changedProducts) {
const data = {
name: product.name,
category: product.category,
priceInCents: product.priceInCents,
stock: product.stock,
reportedAt: now,
};
await tx.product.upsert({
where: { venueId_productId: { venueId: device.venueId, productId: product.id } },
create: { venueId: device.venueId, productId: product.id, ...data },
update: data,
});
}
}
// The terminal's outcome is the truth, even for an adjustment cancelled while it was on its way
for (const result of stockResults.accepted) {
const { count } = await tx.stockAdjustment.updateMany({
where: { id: result.id, venueId: device.venueId, status: { in: ['pending', 'cancelled'] } },
data: {
status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null,
result: result.reason ?? null,
processedAt: now,
},
});
stockResultsRecorded += count;
}
if (events.accepted.length > 0) {
await tx.tabEvent.createMany({
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
@@ -203,10 +266,19 @@ export class DevicesService {
select: { customerName: true },
});
// Sent on every sync until the terminal reports an outcome; the terminal dedupes by id
const stockAdjustments = await this.prisma.stockAdjustment.findMany({
where: { venueId: device.venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
take: 500,
select: { id: true, productId: true, type: true, quantity: true, note: true, requestedBy: true, createdAt: true },
});
const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
if (productsChanged || stockResultsRecorded > 0) topics.push('stock');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
@@ -227,6 +299,11 @@ export class DevicesService {
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never stored: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
stockAdjustments,
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected,
};
}
@@ -314,6 +391,13 @@ export class DevicesService {
}
}
type StoredProduct = { name: string; category: string | null; priceInCents: number; stock: number | null };
function sameProduct(stored: StoredProduct, reported: ParsedProduct): boolean {
return stored.name === reported.name && stored.category === reported.category
&& stored.priceInCents === reported.priceInCents && stored.stock === reported.stock;
}
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
@@ -0,0 +1,55 @@
import { IsString, IsIn, IsInt, Min, IsNotEmpty, MaxLength, IsOptional, IsUUID, ValidateIf } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportedProductDto {
@ApiProperty({ example: 'local-product-id', description: "The terminal's own id for the product" })
@IsString()
@IsNotEmpty()
@MaxLength(100)
id: string;
@ApiProperty({ example: 'Cola' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank' })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
priceInCents: number;
@ApiProperty({ example: 48, nullable: true, description: 'Null when stock is not tracked; may be negative' })
@ValidateIf((product: ReportedProductDto) => product.stock !== null)
@IsInt()
stock: number | null;
}
export const STOCK_RESULT_STATUSES = ['applied', 'rejected'] as const;
export class StockAdjustmentResultDto {
@ApiProperty({ description: 'The stock adjustment id from the sync response' })
@IsUUID()
id: string;
@ApiProperty({ enum: STOCK_RESULT_STATUSES })
@IsIn(STOCK_RESULT_STATUSES)
status: (typeof STOCK_RESULT_STATUSES)[number];
@ApiPropertyOptional({ example: 72, description: 'applied: the stock right after applying it' })
@ValidateIf((result: StockAdjustmentResultDto) => result.status === 'applied')
@IsInt()
stockAfter?: number;
@ApiPropertyOptional({ example: 'unknown_product', description: 'rejected: why' })
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}
+16 -1
View File
@@ -77,4 +77,19 @@ export class SyncDto {
@IsArray()
@ArrayMaxSize(1000)
events?: unknown[];
}
// Validated one by one in the service, like events
@ApiPropertyOptional({
description: "ReportedProductDto[]: the terminal's complete product catalog with stock. Omitted by app versions without stock",
})
@IsOptional()
@IsArray()
@ArrayMaxSize(2000)
products?: unknown[];
@ApiPropertyOptional({ description: 'StockAdjustmentResultDto[]: outcomes of stock adjustments the terminal received' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
stockAdjustmentResults?: unknown[];
}
+54
View File
@@ -0,0 +1,54 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ReportedProductDto, StockAdjustmentResultDto } from './dto/stock-sync.dto';
import { describe } from './tab-events';
export type Rejected = { id: string | null; reason: string };
/**
* Validates entries one by one, like tab events: one bad product or result is skipped on its own
* instead of failing the sync (and with it the open tabs and sales).
*/
function parseEach<T extends object>(cls: new () => T, raw: unknown[]): { accepted: T[]; rejected: Rejected[] } {
const accepted: T[] = [];
const rejected: Rejected[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'must be an object' });
continue;
}
const entry = plainToInstance(cls, value);
const errors = validateSync(entry, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
accepted.push(entry);
}
return { accepted, rejected };
}
export function parseProducts(raw: unknown[]) {
const { accepted, rejected } = parseEach(ReportedProductDto, raw);
// A duplicated id would make the upserts fight; the last one reported wins
const byId = new Map(accepted.map((product) => [product.id, product]));
return {
products: [...byId.values()].map((p) => ({
id: p.id,
name: p.name.trim(),
category: p.category?.trim() || null,
priceInCents: p.priceInCents,
stock: p.stock ?? null,
})),
rejected,
};
}
export type ParsedProduct = ReturnType<typeof parseProducts>['products'][number];
export function parseStockResults(raw: unknown[]) {
return parseEach(StockAdjustmentResultDto, raw);
}
+1 -1
View File
@@ -64,7 +64,7 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
};
}
function describe(errors: ValidationError[]): string {
export function describe(errors: ValidationError[]): string {
return errors
.flatMap((error) => [
...Object.values(error.constraints ?? {}),
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Namespace } from 'socket.io';
/** What changed; the dashboard refetches the matching data over the normal API. */
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users';
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users' | 'stock';
export const venueRoom = (venueId: string) => `venue:${venueId}`;
@@ -0,0 +1,43 @@
import {
IsString, IsIn, IsInt, Min, Max, IsNotEmpty, MaxLength, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize,
ValidateNested,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export const STOCK_ADJUSTMENT_TYPES = ['add', 'set'] as const;
export type StockAdjustmentType = (typeof STOCK_ADJUSTMENT_TYPES)[number];
export class StockAdjustmentEntryDto {
@ApiProperty({ example: 'local-product-id' })
@IsString()
@IsNotEmpty()
productId: string;
@ApiProperty({ enum: STOCK_ADJUSTMENT_TYPES, description: 'add: change the stock by quantity; set: the stock is now quantity' })
@IsIn(STOCK_ADJUSTMENT_TYPES)
type: StockAdjustmentType;
@ApiProperty({ example: 24, description: 'add: non-zero, negative to take stock out; set: zero or more' })
@IsInt()
@Min(-100000)
@Max(100000)
quantity: number;
@ApiPropertyOptional({ example: 'Delivery from the brewery' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsString()
@MaxLength(200)
note?: string;
}
export class CreateStockAdjustmentsDto {
@ApiProperty({ type: [StockAdjustmentEntryDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => StockAdjustmentEntryDto)
adjustments: StockAdjustmentEntryDto[];
}
@@ -0,0 +1,11 @@
import { IsInt, Min, Max, ValidateIf } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateProductDto {
@ApiProperty({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold: number | null;
}
+66
View File
@@ -0,0 +1,66 @@
import { Controller, Get, Post, Put, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { StockService } from './stock.service';
import { CreateStockAdjustmentsDto } from './dto/create-stock-adjustments.dto';
import { UpdateProductDto } from './dto/update-product.dto';
@ApiTags('Stock')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Controller('venues/:venueId/stock')
export class StockController {
constructor(private service: StockService) {}
@Get()
@ApiOperation({ summary: "The terminal's products with their stock and pending adjustments" })
async findAll(@Param('venueId') venueId: string) {
return this.service.findAll(venueId);
}
@Get('adjustments')
@ApiOperation({ summary: 'Stock adjustment history' })
@ApiQuery({ name: 'productId', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
async history(
@Param('venueId') venueId: string,
@Query('productId') productId?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.service.history(venueId, {
productId: productId || undefined,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
}
// Viewers (bar staff) change stock too; only the warning level is an admin setting
@Post('adjustments')
@ApiOperation({ summary: 'Add to, take from or count the stock of one or more products' })
async create(@Param('venueId') venueId: string, @Body() dto: CreateStockAdjustmentsDto, @Req() req: any) {
return this.service.createAdjustments(venueId, dto.adjustments, req.user);
}
@Post('adjustments/:id/cancel')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cancel a stock adjustment the terminal has not processed yet' })
async cancel(@Param('venueId') venueId: string, @Param('id') id: string, @Req() req: any) {
return this.service.cancelAdjustment(venueId, id, req.user);
}
@Put('products/:productId')
@Roles('admin')
@ApiOperation({ summary: 'Set the low stock warning level of a product (admin)' })
async updateProduct(
@Param('venueId') venueId: string,
@Param('productId') productId: string,
@Body() dto: UpdateProductDto,
) {
return this.service.updateProduct(venueId, productId, dto.lowStockThreshold);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { StockController } from './stock.controller';
import { StockService } from './stock.service';
@Module({
controllers: [StockController],
providers: [StockService],
})
export class StockModule {}
+86
View File
@@ -0,0 +1,86 @@
import { StockService } from './stock.service';
const user = { id: 'u1', email: 'bram@example.com' };
function createService({ products = [] as { productId: string; name: string; stock: number | null }[], cancelled = 1, existing = null as unknown } = {}) {
const prisma = {
product: { findMany: jest.fn().mockResolvedValue(products), updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
stockAdjustment: {
create: jest.fn((args: { data: Record<string, unknown> }) => ({ id: 'a1', ...args.data })),
updateMany: jest.fn().mockResolvedValue({ count: cancelled }),
findFirst: jest.fn().mockResolvedValue(existing),
},
$transaction: jest.fn((operations: unknown[]) => Promise.all(operations)),
};
const audit = { log: jest.fn() };
const realtime = { notify: jest.fn() };
const service = new StockService(prisma as any, audit as any, realtime as any);
return { service, prisma, audit, realtime };
}
const cola = { productId: 'p1', name: 'Cola', stock: 10 };
const water = { productId: 'p2', name: 'Water', stock: null };
describe('StockService.createAdjustments', () => {
it('creates one pending adjustment per entry with the product name and requester', async () => {
const { service, prisma, realtime } = createService({ products: [cola, water] });
const result = await service.createAdjustments('v1', [
{ productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' },
{ productId: 'p2', type: 'set', quantity: 12 },
], user);
expect(prisma.stockAdjustment.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ venueId: 'v1', productId: 'p1', productName: 'Cola', type: 'add', quantity: 24, note: 'Delivery', requestedBy: 'bram@example.com' }),
}));
expect(result.adjustments).toHaveLength(2);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('rejects a negative count and adding zero', async () => {
const { service } = createService({ products: [cola] });
await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'set', quantity: -1 }], user))
.rejects.toMatchObject({ response: { code: 'invalid_quantity' } });
await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'add', quantity: 0 }], user))
.rejects.toMatchObject({ response: { code: 'invalid_quantity' } });
});
it('rejects unknown products and adding to an untracked product, creating nothing', async () => {
const { service, prisma } = createService({ products: [cola, water] });
await expect(service.createAdjustments('v1', [{ productId: 'p9', type: 'set', quantity: 1 }], user))
.rejects.toMatchObject({ response: { code: 'product_not_found' } });
await expect(service.createAdjustments('v1', [
{ productId: 'p1', type: 'add', quantity: 6 },
{ productId: 'p2', type: 'add', quantity: 6 },
], user)).rejects.toMatchObject({ response: { code: 'stock_not_tracked' } });
expect(prisma.stockAdjustment.create).not.toHaveBeenCalled();
});
});
describe('StockService.cancelAdjustment', () => {
it('cancels a pending adjustment', async () => {
const { service, prisma, realtime } = createService();
await expect(service.cancelAdjustment('v1', 'a1', user)).resolves.toEqual({ ok: true });
expect(prisma.stockAdjustment.updateMany).toHaveBeenCalledWith({
where: { id: 'a1', venueId: 'v1', status: 'pending' },
data: { status: 'cancelled', processedAt: expect.any(Date) },
});
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('refuses once the terminal processed it', async () => {
const { service } = createService({ cancelled: 0, existing: { id: 'a1', status: 'applied' } });
await expect(service.cancelAdjustment('v1', 'a1', user)).rejects.toMatchObject({ response: { code: 'adjustment_not_pending' } });
});
it('is idempotent and 404s for another venue', async () => {
await expect(createService({ cancelled: 0, existing: { id: 'a1', status: 'cancelled' } }).service.cancelAdjustment('v1', 'a1', user))
.resolves.toEqual({ ok: true });
await expect(createService({ cancelled: 0 }).service.cancelAdjustment('v1', 'a1', user))
.rejects.toMatchObject({ response: { code: 'adjustment_not_found' } });
});
});
+172
View File
@@ -0,0 +1,172 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto';
const adjustmentSelect = {
id: true,
productId: true,
productName: true,
type: true,
quantity: true,
note: true,
requestedBy: true,
status: true,
stockAfter: true,
result: true,
createdAt: true,
processedAt: true,
} as const;
@Injectable()
export class StockService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
) {}
async findAll(venueId: string) {
const [products, pending] = await Promise.all([
this.prisma.product.findMany({
where: { venueId },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
}),
this.prisma.stockAdjustment.findMany({
where: { venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
select: adjustmentSelect,
}),
]);
const pendingByProduct = new Map<string, typeof pending>();
for (const adjustment of pending) {
pendingByProduct.set(adjustment.productId, [...(pendingByProduct.get(adjustment.productId) ?? []), adjustment]);
}
return {
products: products.map((p) => ({
productId: p.productId,
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
stock: p.stock,
lowStockThreshold: p.lowStockThreshold,
reportedAt: p.reportedAt,
pendingAdjustments: pendingByProduct.get(p.productId) ?? [],
})),
// Null until a terminal running an app version with stock has synced
reportedAt: products.reduce<Date | null>((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null),
};
}
async history(venueId: string, options: { productId?: string; limit: number; offset: number }) {
const where = { venueId, ...(options.productId ? { productId: options.productId } : {}) };
const [adjustments, total] = await Promise.all([
this.prisma.stockAdjustment.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options.limit,
skip: options.offset,
select: adjustmentSelect,
}),
this.prisma.stockAdjustment.count({ where }),
]);
return { adjustments, total };
}
async createAdjustments(venueId: string, entries: StockAdjustmentEntryDto[], user: { id: string; email: string; name?: string }) {
for (const entry of entries) {
if (entry.type === 'set' && entry.quantity < 0) {
throw new BadRequestException({ code: 'invalid_quantity', message: 'A stock count cannot be negative' });
}
if (entry.type === 'add' && entry.quantity === 0) {
throw new BadRequestException({ code: 'invalid_quantity', message: 'Adding zero changes nothing' });
}
}
const products = await this.prisma.product.findMany({
where: { venueId, productId: { in: entries.map((e) => e.productId) } },
select: { productId: true, name: true, stock: true },
});
const productsById = new Map(products.map((p) => [p.productId, p]));
for (const entry of entries) {
const product = productsById.get(entry.productId);
if (!product) {
throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' });
}
// Counting is how tracking starts; adding to an untracked product has nothing to add to
if (entry.type === 'add' && product.stock === null) {
throw new BadRequestException({
code: 'stock_not_tracked',
message: `Stock is not tracked for ${product.name}. Count it first.`,
});
}
}
const requestedBy = user.name || user.email;
const created = await this.prisma.$transaction(
entries.map((entry) => this.prisma.stockAdjustment.create({
data: {
venueId,
productId: entry.productId,
productName: productsById.get(entry.productId)!.name,
type: entry.type,
quantity: entry.quantity,
note: entry.note ?? null,
requestedBy,
requestedById: user.id,
},
select: adjustmentSelect,
})),
);
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustments_created', {
adjustments: created.map((a) => ({ id: a.id, productId: a.productId, type: a.type, quantity: a.quantity })),
});
this.realtime.notify(venueId, 'stock');
return { adjustments: created };
}
async cancelAdjustment(venueId: string, id: string, user: { id: string }) {
const { count } = await this.prisma.stockAdjustment.updateMany({
where: { id, venueId, status: 'pending' },
data: { status: 'cancelled', processedAt: new Date() },
});
if (count === 0) {
const existing = await this.prisma.stockAdjustment.findFirst({ where: { id, venueId } });
if (!existing) {
throw new NotFoundException({ code: 'adjustment_not_found', message: 'Stock adjustment not found' });
}
if (existing.status === 'cancelled') return { ok: true }; // Idempotent
throw new ConflictException({
code: 'adjustment_not_pending',
message: 'The terminal already processed this stock adjustment',
});
}
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustment_cancelled', { adjustmentId: id });
this.realtime.notify(venueId, 'stock');
return { ok: true };
}
async updateProduct(venueId: string, productId: string, lowStockThreshold: number | null) {
const { count } = await this.prisma.product.updateMany({
where: { venueId, productId },
data: { lowStockThreshold },
});
if (count === 0) {
throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' });
}
this.realtime.notify(venueId, 'stock');
return { ok: true };
}
}