feat: make products server managed
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 23:55:40 +02:00
parent 0ca9f4d16a
commit e7f59f7583
14 changed files with 697 additions and 181 deletions
+125 -36
View File
@@ -1,8 +1,11 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
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';
import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto';
import { VenueLedger } from '../tabs/venue-ledger';
import { applyPendingAdjustments } from './stock-adjustments';
@@ -29,36 +32,16 @@ export class StockService {
private realtime: RealtimeService,
) {}
/** Adjustments are applied at once, so there is nothing pending to show with the products. */
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]);
}
const products = await this.prisma.product.findMany({
where: { venueId },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
});
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
products: products.map(productView),
// Null until the venue has products
reportedAt: products.reduce<Date | null>((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null),
};
}
@@ -172,17 +155,123 @@ export class StockService {
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' });
}
/**
* Adds a product to the venue's catalog; the terminals get it on their next sync. A starting count tracks its stock
* from now on and is kept in the stock history.
*/
async createProduct(venueId: string, dto: CreateProductDto, user: { id: string; email: string; name?: string }) {
const product = await this.prisma.$transaction(async (tx) => {
const now = new Date();
// Locked like a sync, so a name check and the stock can't race with one
await VenueLedger.open(tx, venueId);
await this.ensureNameFree(tx, venueId, dto.name);
const stock = dto.stock ?? null;
const created = await tx.product.create({
data: {
venueId,
productId: uuidv4(),
name: dto.name,
category: dto.category ?? null,
priceInCents: dto.priceInCents,
stock,
countedAt: stock === null ? null : now,
lowStockThreshold: dto.lowStockThreshold ?? null,
reportedAt: now,
},
});
if (stock !== null) {
await tx.stockAdjustment.create({
data: {
venueId,
productId: created.productId,
productName: created.name,
type: 'set',
quantity: stock,
requestedBy: user.name || user.email,
requestedById: user.id,
status: 'applied',
stockAfter: stock,
createdAt: now,
processedAt: now,
},
});
}
return created;
});
await this.auditService.log(venueId, 'client_user', user.id, 'product_created', {
productId: product.productId, name: product.name, priceInCents: product.priceInCents,
});
this.realtime.notify(venueId, 'stock');
return { ok: true };
return productView(product);
}
/**
* Changes a product's name, category, price, warning level, or archives it (a deleted product stays, hidden, because
* tabs and history refer to it). Lines already on a tab keep the name and price they were rung up with.
*/
async updateProduct(venueId: string, productId: string, dto: UpdateProductDto, 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 archived = dto.archived ?? existing.archivedAt != null;
const name = dto.name ?? existing.name;
// A product coming back, or renamed, must not take an active product's name
if (!archived && (name !== existing.name || existing.archivedAt != null)) {
await this.ensureNameFree(tx, venueId, name, productId);
}
return tx.product.update({
where: { venueId_productId: { venueId, productId } },
data: {
name,
...(dto.category !== undefined ? { category: dto.category } : {}),
...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}),
...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}),
archivedAt: archived ? (existing.archivedAt ?? new Date()) : null,
reportedAt: new Date(),
},
});
});
await this.auditService.log(venueId, 'client_user', user.id, 'product_updated', { productId, changes: dto });
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 },
select: { productId: true, name: true },
});
const key = name.trim().toLowerCase();
if (products.some((p) => p.productId !== exceptProductId && p.name.trim().toLowerCase() === key)) {
throw new ConflictException({ code: 'product_name_exists', message: 'A product with this name already exists' });
}
}
}
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;
}) {
return {
productId: p.productId,
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
stock: p.stock,
lowStockThreshold: p.lowStockThreshold,
// The latest count, on the dashboard or a terminal: how fresh the stock is
countedAt: p.countedAt,
reportedAt: p.reportedAt,
archivedAt: p.archivedAt,
};
}