This commit is contained in:
@@ -11,6 +11,18 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { PrismaClient } = require('@prisma/client');
|
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) {
|
function run(command) {
|
||||||
console.log(`[deploy] ${command}`);
|
console.log(`[deploy] ${command}`);
|
||||||
execSync(command, { stdio: 'inherit' });
|
execSync(command, { stdio: 'inherit' });
|
||||||
@@ -27,6 +39,14 @@ async function main() {
|
|||||||
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`;
|
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`;
|
||||||
hasMigrationsTable = migrations;
|
hasMigrationsTable = migrations;
|
||||||
hasTables = tables;
|
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 {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,11 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { getJwtSecret } from '../jwt-secret';
|
import { getJwtSecret } from '../jwt-secret';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor() {
|
constructor(private prisma: PrismaService) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
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) {
|
async validate(payload: any) {
|
||||||
return {
|
const user = await this.prisma.clientUser.findUnique({
|
||||||
id: payload.sub,
|
where: { id: payload.sub },
|
||||||
email: payload.email,
|
select: { id: true, email: true, name: true, role: true, venueId: true },
|
||||||
role: payload.role,
|
});
|
||||||
venueId: payload.venueId,
|
if (!user) {
|
||||||
};
|
throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,9 +90,11 @@ export class DevicesService {
|
|||||||
if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true;
|
if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true;
|
||||||
continue;
|
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);
|
const settlement = ledger.tabs.apply(event, occurredAt);
|
||||||
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
|
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
|
||||||
ledger.stock.apply(event, occurredAt);
|
ledger.stock.apply(stockEvent, occurredAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
describe('seedTabs and tabSummary', () => {
|
||||||
it("turns a terminal's snapshot into tabs the dashboard sums up the same", () => {
|
it("turns a terminal's snapshot into tabs the dashboard sums up the same", () => {
|
||||||
const [seeded] = seedTabs([{
|
const [seeded] = seedTabs([{
|
||||||
|
|||||||
@@ -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. */
|
/** 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 {
|
settleAll(tabId: string, settlement: { id: string; settledAt: Date; paymentMethod: string }): SettlementDraft | null {
|
||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
|
|||||||
@@ -40,7 +40,15 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
// The language lives in localStorage, so the server can't know it; the inline script sets it before first paint
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `try{var l=localStorage.getItem('kooltap-lang');if(l==='en'||l==='nl')document.documentElement.lang=l}catch(e){}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
<Script
|
<Script
|
||||||
|
|||||||
Reference in New Issue
Block a user