diff --git a/apps/api/prisma/deploy.js b/apps/api/prisma/deploy.js index 5b496bc..d8890d2 100644 --- a/apps/api/prisma/deploy.js +++ b/apps/api/prisma/deploy.js @@ -11,6 +11,18 @@ const fs = require('fs'); const path = require('path'); const { PrismaClient } = require('@prisma/client'); +// Migrations that were first created with dates in the future, renamed to when they were written. +// The SQL is unchanged, so databases that already applied them only need the recorded name updated. +const RENAMED_MIGRATIONS = { + '20261020120000_tab_events': '20261019212336_tab_events', + '20261021120000_stock': '20261020124522_stock', + '20261022120000_server_tabs_and_stock': '20261020141836_server_tabs_and_stock', + '20261023120000_products_on_dashboard': '20261020215540_products_on_dashboard', + '20261024120000_product_images_and_events': '20261021104519_product_images_and_events', + '20261025120000_sold_unit_view': '20261021225808_sold_unit_view', + '20261026120000_drop_tab_snapshots': '20261021233219_drop_tab_snapshots', +}; + function run(command) { console.log(`[deploy] ${command}`); execSync(command, { stdio: 'inherit' }); @@ -27,6 +39,14 @@ async function main() { EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`; hasMigrationsTable = migrations; hasTables = tables; + + if (hasMigrationsTable) { + for (const [oldName, newName] of Object.entries(RENAMED_MIGRATIONS)) { + const renamed = await prisma.$executeRaw` + UPDATE "_prisma_migrations" SET "migration_name" = ${newName} WHERE "migration_name" = ${oldName}`; + if (renamed > 0) console.log(`[deploy] Renamed migration ${oldName} -> ${newName}`); + } + } } finally { await prisma.$disconnect(); } diff --git a/apps/api/prisma/migrations/20261020120000_tab_events/migration.sql b/apps/api/prisma/migrations/20261019212336_tab_events/migration.sql similarity index 100% rename from apps/api/prisma/migrations/20261020120000_tab_events/migration.sql rename to apps/api/prisma/migrations/20261019212336_tab_events/migration.sql diff --git a/apps/api/prisma/migrations/20261021120000_stock/migration.sql b/apps/api/prisma/migrations/20261020124522_stock/migration.sql similarity index 100% rename from apps/api/prisma/migrations/20261021120000_stock/migration.sql rename to apps/api/prisma/migrations/20261020124522_stock/migration.sql diff --git a/apps/api/prisma/migrations/20261022120000_server_tabs_and_stock/migration.sql b/apps/api/prisma/migrations/20261020141836_server_tabs_and_stock/migration.sql similarity index 100% rename from apps/api/prisma/migrations/20261022120000_server_tabs_and_stock/migration.sql rename to apps/api/prisma/migrations/20261020141836_server_tabs_and_stock/migration.sql diff --git a/apps/api/prisma/migrations/20261023120000_products_on_dashboard/migration.sql b/apps/api/prisma/migrations/20261020215540_products_on_dashboard/migration.sql similarity index 100% rename from apps/api/prisma/migrations/20261023120000_products_on_dashboard/migration.sql rename to apps/api/prisma/migrations/20261020215540_products_on_dashboard/migration.sql diff --git a/apps/api/prisma/migrations/20261024120000_product_images_and_events/migration.sql b/apps/api/prisma/migrations/20261021104519_product_images_and_events/migration.sql similarity index 100% rename from apps/api/prisma/migrations/20261024120000_product_images_and_events/migration.sql rename to apps/api/prisma/migrations/20261021104519_product_images_and_events/migration.sql diff --git a/apps/api/prisma/migrations/20261025120000_sold_unit_view/migration.sql b/apps/api/prisma/migrations/20261021225808_sold_unit_view/migration.sql similarity index 100% rename from apps/api/prisma/migrations/20261025120000_sold_unit_view/migration.sql rename to apps/api/prisma/migrations/20261021225808_sold_unit_view/migration.sql diff --git a/apps/api/prisma/migrations/20261026120000_drop_tab_snapshots/migration.sql b/apps/api/prisma/migrations/20261021233219_drop_tab_snapshots/migration.sql similarity index 100% rename from apps/api/prisma/migrations/20261026120000_drop_tab_snapshots/migration.sql rename to apps/api/prisma/migrations/20261021233219_drop_tab_snapshots/migration.sql diff --git a/apps/api/src/auth/strategies/jwt.strategy.spec.ts b/apps/api/src/auth/strategies/jwt.strategy.spec.ts new file mode 100644 index 0000000..2ae9f0a --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,20 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtStrategy } from './jwt.strategy'; + +const mona = { id: 'user-1', email: 'mona@example.com', name: 'Mona', role: 'manager', venueId: 'venue-1' }; + +function strategy(users: (typeof mona)[]) { + const prisma = { clientUser: { findUnique: async ({ where }: any) => users.find((u) => u.id === where.id) ?? null } }; + return new JwtStrategy(prisma as any); +} + +describe('JwtStrategy', () => { + it('takes the user as it is now, not as it was when the token was issued', async () => { + const user = await strategy([{ ...mona, role: 'admin' }]).validate({ sub: 'user-1', email: mona.email, role: 'manager', venueId: 'venue-1' }); + expect(user).toEqual({ ...mona, role: 'admin' }); + }); + + it('refuses the token of a deleted user', async () => { + await expect(strategy([]).validate({ sub: 'user-1', role: 'admin', venueId: 'venue-1' })).rejects.toBeInstanceOf(UnauthorizedException); + }); +}); diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index b87b712..8eab809 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -2,10 +2,11 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { getJwtSecret } from '../jwt-secret'; +import { PrismaService } from '../../prisma/prisma.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor() { + constructor(private prisma: PrismaService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -13,12 +14,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } + // Looked up on every request: a deleted user loses access at once instead of when the token expires, and a + // changed role or name applies right away async validate(payload: any) { - return { - id: payload.sub, - email: payload.email, - role: payload.role, - venueId: payload.venueId, - }; + const user = await this.prisma.clientUser.findUnique({ + where: { id: payload.sub }, + select: { id: true, email: true, name: true, role: true, venueId: true }, + }); + if (!user) { + throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' }); + } + return user; } } \ No newline at end of file diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 6fcdad0..e4e2833 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -90,9 +90,11 @@ export class DevicesService { if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true; continue; } + // Only drinks that were still on the tab go back into the stock: one settled or deleted meanwhile was sold + const stockEvent = event.type === 'line_removed' ? { ...event, quantity: ledger.tabs.removable(event) } : event; const settlement = ledger.tabs.apply(event, occurredAt); if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id }); - ledger.stock.apply(event, occurredAt); + ledger.stock.apply(stockEvent, occurredAt); } } diff --git a/apps/api/src/tabs/tab-state.spec.ts b/apps/api/src/tabs/tab-state.spec.ts index fa9494e..b215f9a 100644 --- a/apps/api/src/tabs/tab-state.spec.ts +++ b/apps/api/src/tabs/tab-state.spec.ts @@ -124,6 +124,18 @@ describe('findLine', () => { }); }); +describe('VenueTabs.removable', () => { + it('counts only drinks still on the line, so a removal after a settlement returns nothing to the stock', () => { + const tabs = withCola(3); + const removal = event({ type: 'line_removed', lineId: 'line-1', quantity: 5, ...cola }); + expect(tabs.removable(removal)).toBe(3); + + tabs.apply(event({ type: 'settled', amountInCents: 750, paymentMethod: 'cash', items: [{ ...cola, quantity: 3 }] }), time); + expect(tabs.removable(removal)).toBe(0); + expect(tabs.removable(event({ type: 'line_removed', tabId: 'gone', quantity: 1, ...cola }))).toBe(0); + }); +}); + describe('seedTabs and tabSummary', () => { it("turns a terminal's snapshot into tabs the dashboard sums up the same", () => { const [seeded] = seedTabs([{ diff --git a/apps/api/src/tabs/tab-state.ts b/apps/api/src/tabs/tab-state.ts index 6ef19ad..fe1ed73 100644 --- a/apps/api/src/tabs/tab-state.ts +++ b/apps/api/src/tabs/tab-state.ts @@ -171,6 +171,16 @@ export class VenueTabs { } } + /** + * How many drinks a line_removed event can actually take off: none when the tab or line is gone (settled or + * deleted elsewhere while the terminal was offline). + */ + removable(event: TabEventDto): number { + const tab = event.tabId ? this.tabs.get(event.tabId) : undefined; + const line = tab && findLine(tab, event); + return line ? Math.min(event.quantity ?? 0, lineQuantity(line)) : 0; + } + /** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */ settleAll(tabId: string, settlement: { id: string; settledAt: Date; paymentMethod: string }): SettlementDraft | null { const tab = this.tabs.get(tabId); diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 9579147..9d9fb6a 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -40,7 +40,15 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + // The language lives in localStorage, so the server can't know it; the inline script sets it before first paint + +
+ +