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
+58 -19
View File
@@ -247,21 +247,67 @@ describe('DevicesService.sync: settlements feed', () => {
});
});
/** A product added on the dashboard, untracked. */
function addProduct(prisma: FakePrisma, productId = 'cola', fields: Record<string, unknown> = {}) {
prisma.product.rows.push({
venueId: 'v1', productId, name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: null, countedAt: null,
lowStockThreshold: null, archivedAt: null, reportedAt: new Date(), ...fields,
});
}
describe('DevicesService.sync: the dashboard manages the catalog', () => {
it('sends every product, archived ones marked, and ignores what a tablet reports', async () => {
const { sync, tabletA, prisma } = setup();
addProduct(prisma, 'cola', { lowStockThreshold: 6 });
addProduct(prisma, 'old', { name: 'Old', category: null, archivedAt: new Date() });
const result = await sync(tabletA, {
products: [
{ id: 'cola', name: 'Renamed on the tablet', category: null, priceInCents: 999, stock: 3 },
{ id: 'local', name: 'Seeded on the tablet', category: null, priceInCents: 100, stock: 24 },
],
});
// By category, products without one last (as Postgres sorts)
expect(result.catalog).toEqual([
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, lowStockThreshold: 6, archived: false },
{ id: 'old', name: 'Old', category: null, priceInCents: 250, lowStockThreshold: null, archived: true },
]);
expect(prisma.product.rows).toHaveLength(2);
});
it("takes over the tablet's catalog when the venue is taken over", async () => {
const { sync, tabletA } = setup({ seeded: false });
const result = await sync(tabletA, { products: catalog(12) });
expect(result.catalog).toMatchObject([{ id: 'cola', name: 'Cola', archived: false }]);
expect(colaStock(result)).toBe(12);
});
});
describe('DevicesService.sync: the server holds the stock', () => {
async function trackedCola(sync: ReturnType<typeof setup>['sync'], tablet: Device, stock = 48) {
return sync(tablet, { products: catalog(), events: [events.stockSet('cola', stock, -10)] });
return sync(tablet, { events: [events.stockSet('cola', stock, -10)] });
}
it('starts a new product untracked and ignores the stock in the catalog', async () => {
const { sync, tabletA } = setup();
/** Every test here starts with Cola on the dashboard. */
function setupWithCola() {
const venue = setup();
addProduct(venue.prisma);
return venue;
}
const result = await sync(tabletA, { products: catalog(99) });
it('ignores a count for a product the venue does not have', async () => {
const { sync, tabletA } = setupWithCola();
const result = await sync(tabletA, { events: [events.stockSet('local', 24)] });
expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]);
});
it('counts the sales of every tablet once', async () => {
const { sync, tabletA, tabletB } = setup();
const { sync, tabletA, tabletB } = setupWithCola();
await trackedCola(sync, tabletA);
const sale = events.line('line_added', 't1', 2, { minutes: 1 });
@@ -273,7 +319,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
});
it('gives stock back for drinks taken off, but not for a settlement', async () => {
const { sync, tabletA } = setup();
const { sync, tabletA } = setupWithCola();
await trackedCola(sync, tabletA);
const result = await sync(tabletA, {
@@ -289,7 +335,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
});
it('keeps a count when sales from before it arrive later', async () => {
const { sync, tabletA, tabletB } = setup();
const { sync, tabletA, tabletB } = setupWithCola();
await trackedCola(sync, tabletA);
await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] });
@@ -309,7 +355,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
});
it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => {
const { sync, tabletA, tabletB, prisma } = setup();
const { sync, tabletA, tabletB, prisma } = setupWithCola();
await trackedCola(sync, tabletA);
prisma.stockAdjustment.rows.push({
id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24,
@@ -326,7 +372,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
});
it('records results from older tablets that applied an adjustment themselves', async () => {
const { sync, tabletA, prisma } = setup();
const { sync, tabletA, prisma } = setupWithCola();
await trackedCola(sync, tabletA);
const id = uuidv4();
prisma.stockAdjustment.rows.push({
@@ -340,14 +386,6 @@ describe('DevicesService.sync: the server holds the stock', () => {
expect(colaStock(result)).toBe(48);
});
it('never removes a product another tablet does not know', async () => {
const { sync, tabletA, tabletB } = setup();
await trackedCola(sync, tabletA);
const result = await sync(tabletB, { products: [{ id: 'water', name: 'Water', category: null, priceInCents: 200, stock: 3 }] });
expect(result.stock.map((s) => s.productId).sort()).toEqual(['cola', 'water']);
});
});
describe('DevicesService.sync live update signals', () => {
@@ -362,9 +400,10 @@ describe('DevicesService.sync live update signals', () => {
});
it('signals "tabs" and "stock" when they changed', async () => {
const { sync, tabletA, realtime } = setup();
const { sync, tabletA, realtime, prisma } = setup();
addProduct(prisma);
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.stockSet('cola', 5)] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
});
+22 -5
View File
@@ -58,8 +58,10 @@ export class DevicesService {
// Locks the venue: other terminals' syncs wait, so no change is lost
const ledger = await VenueLedger.open(tx, venueId);
// 1. The catalog: names and prices only, the stock is the server's
const productsChanged = reported ? await this.saveProducts(tx, ledger, reported.products, now) : false;
// 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
const productsChanged = reported && !ledger.seeded
? await this.saveProducts(tx, ledger, reported.products, now)
: false;
// 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
@@ -112,10 +114,16 @@ export class DevicesService {
await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now);
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 },
});
return {
ledger,
feed,
catalog,
topics: {
tabs: ledger.changedTabs,
plannedTabs: plannedDelivered > 0,
@@ -156,7 +164,7 @@ export class DevicesService {
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
const { ledger, feed } = synced;
const { ledger, feed, catalog } = synced;
return {
cursor,
commands: commands.map((c) => ({
@@ -182,6 +190,15 @@ export class DevicesService {
rejectedStockAdjustmentResults: stockResults.rejected,
// The venue's complete state after this request's events: the terminal replaces its own with it
tabs: ledger.tabs.all().map(tabJson),
// Every product of the venue; an archived one is hidden on the terminal (tabs and history refer to it)
catalog: catalog.map((p) => ({
id: p.productId,
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
lowStockThreshold: p.lowStockThreshold,
archived: p.archivedAt !== null,
})),
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
settlements: feed.settlements,
@@ -190,8 +207,8 @@ export class DevicesService {
}
/**
* Upserts the reported products without their stock, and never removes one: another terminal may still sell it.
* Returns whether anything the dashboard shows changed.
* Takes over the terminal's catalog on the first sync since the server holds the tabs; the dashboard manages it
* from then on. Never removes a product. Returns whether anything the dashboard shows changed.
*/
private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) {
const stored = await tx.product.findMany({
+77 -4
View File
@@ -1,11 +1,84 @@
import { IsInt, Min, Max, ValidateIf } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, Min, Max, ValidateIf, IsString, IsNotEmpty, MaxLength, IsOptional, IsBoolean } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
// An empty category is no category
const trimToNull = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() || null : value);
export class CreateProductDto {
@ApiProperty({ example: 'Cola' })
@Transform(trim)
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents: number;
@ApiPropertyOptional({ example: 24, nullable: true, description: 'The starting count; null or left out: stock is not tracked' })
@IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.stock !== null)
@IsInt()
@Min(0)
@Max(100000)
stock?: number | null;
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.lowStockThreshold !== null)
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold?: number | null;
}
/** Only the fields that are sent change. The stock itself changes through stock adjustments. */
export class UpdateProductDto {
@ApiProperty({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@ApiPropertyOptional({ example: 'Cola Zero' })
@IsOptional()
@Transform(trim)
@IsString()
@IsNotEmpty()
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@Transform(trimToNull)
@ValidateIf((dto: UpdateProductDto) => dto.category !== null)
@IsString()
@MaxLength(100)
category?: string | null;
@ApiPropertyOptional({ example: 250 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents?: number;
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@IsOptional()
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold: number | null;
lowStockThreshold?: number | null;
@ApiPropertyOptional({ description: 'true hides the product on the terminals (deleting it); false brings it back' })
@IsOptional()
@IsBoolean()
archived?: boolean;
}
+15 -6
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Put, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
import { Controller, Get, Post, Patch, 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';
@@ -6,7 +6,7 @@ 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';
import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto';
@ApiTags('Stock')
@ApiBearerAuth()
@@ -16,7 +16,7 @@ export class StockController {
constructor(private service: StockService) {}
@Get()
@ApiOperation({ summary: "The terminal's products with their stock and pending adjustments" })
@ApiOperation({ summary: "The venue's products (also archived ones) with their stock" })
async findAll(@Param('venueId') venueId: string) {
return this.service.findAll(venueId);
}
@@ -53,14 +53,23 @@ export class StockController {
return this.service.cancelAdjustment(venueId, id, req.user);
}
@Put('products/:productId')
// The catalog is managed here; the terminals get it on every sync
@Post('products')
@Roles('admin')
@ApiOperation({ summary: 'Set the low stock warning level of a product (admin)' })
@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')
@ApiOperation({ summary: 'Change, archive (delete) or restore a product (admin)' })
async updateProduct(
@Param('venueId') venueId: string,
@Param('productId') productId: string,
@Body() dto: UpdateProductDto,
@Req() req: any,
) {
return this.service.updateProduct(venueId, productId, dto.lowStockThreshold);
return this.service.updateProduct(venueId, productId, dto, req.user);
}
}
+83 -1
View File
@@ -26,7 +26,10 @@ function createWithVenue(products: { productId: string; name: string; stock: num
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
for (const p of products) {
prisma.product.rows.push({ venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, reportedAt: new Date() });
prisma.product.rows.push({
venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, lowStockThreshold: null, archivedAt: null,
reportedAt: new Date(),
});
}
const realtime = { notify: jest.fn() };
const service = new StockService(prisma as any, { log: jest.fn() } as any, realtime as any);
@@ -101,3 +104,82 @@ describe('StockService.cancelAdjustment', () => {
.rejects.toMatchObject({ response: { code: 'adjustment_not_found' } });
});
});
describe('StockService products', () => {
const admin = { id: 'u1', email: 'bram@example.com', name: 'Bram' };
it('adds a product with a starting count, kept in the stock history', async () => {
const { service, prisma, realtime } = createWithVenue([cola]);
const product = await service.createProduct('v1', { name: 'Water', category: 'Frisdrank', priceInCents: 200, stock: 24 }, admin);
expect(product).toMatchObject({ name: 'Water', category: 'Frisdrank', priceInCents: 200, stock: 24, archivedAt: null });
expect(prisma.product.rows.find((p) => p.productId === product.productId)!.countedAt).toBeInstanceOf(Date);
expect(prisma.stockAdjustment.rows).toMatchObject([
{ productId: product.productId, type: 'set', quantity: 24, status: 'applied', stockAfter: 24, requestedBy: 'Bram' },
]);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('adds an untracked product without history', async () => {
const { service, prisma } = createWithVenue([]);
const product = await service.createProduct('v1', { name: 'Tap water', priceInCents: 0 }, admin);
expect(product).toMatchObject({ stock: null, category: null, lowStockThreshold: null });
expect(prisma.stockAdjustment.rows).toEqual([]);
});
it('refuses a second active product with the same name, ignoring case', async () => {
const { service } = createWithVenue([cola]);
await expect(service.createProduct('v1', { name: ' cola ', priceInCents: 250 }, admin))
.rejects.toMatchObject({ response: { code: 'product_name_exists' } });
});
it('changes only the fields that are sent', async () => {
const { service, prisma } = createWithVenue([cola]);
await service.updateProduct('v1', 'p1', { priceInCents: 300, category: null }, admin);
expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola', priceInCents: 300, category: null, stock: 10, archivedAt: null });
});
it('archives a product and brings it back, unless its name was taken meanwhile', async () => {
const { service, prisma } = createWithVenue([cola]);
await service.updateProduct('v1', 'p1', { archived: true }, admin);
expect(prisma.product.rows[0].archivedAt).toBeInstanceOf(Date);
await service.createProduct('v1', { name: 'Cola', priceInCents: 250 }, admin);
await expect(service.updateProduct('v1', 'p1', { archived: false }, admin))
.rejects.toMatchObject({ response: { code: 'product_name_exists' } });
await service.updateProduct('v1', 'p1', { archived: false, name: 'Cola (old)' }, admin);
expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola (old)', archivedAt: null });
});
it('404s for a product of another venue', async () => {
const { service } = createWithVenue([cola]);
await expect(service.updateProduct('v1', 'p9', { priceInCents: 1 }, admin))
.rejects.toMatchObject({ response: { code: 'product_not_found' } });
});
});
describe('StockService.findAll', () => {
it('sends when each product was last counted, archived ones included', async () => {
const countedAt = new Date('2026-10-20T08:00:00Z');
const { service, prisma } = createWithVenue([cola, water]);
prisma.product.rows[0].countedAt = countedAt;
prisma.product.rows[1].archivedAt = new Date();
const { products } = await service.findAll('v1');
expect(products).toEqual([
expect.objectContaining({ productId: 'p1', stock: 10, countedAt, archivedAt: null }),
expect.objectContaining({ productId: 'p2', countedAt: null, archivedAt: expect.any(Date) }),
]);
expect(products[0]).not.toHaveProperty('pendingAdjustments');
});
});
+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,
};
}
+6 -1
View File
@@ -60,6 +60,11 @@ function sort(rows: Row[], orderBy?: Row | Row[]) {
return [...rows].sort((a, b) => {
for (const order of orders) {
const [field, direction] = Object.entries(order)[0];
// Postgres puts nulls last when ascending, first when descending
if ((a[field] ?? null) === null || (b[field] ?? null) === null) {
if ((a[field] ?? null) === (b[field] ?? null)) continue;
return ((a[field] ?? null) === null ? 1 : -1) * (direction === 'asc' ? 1 : -1);
}
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
}
@@ -153,7 +158,7 @@ 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 })),
product: new FakeTable(() => ({ stock: null, countedAt: null, lowStockThreshold: null, archivedAt: null })),
tabEvent: new FakeTable(),
settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'),
stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })),