feat: add realtime

This commit is contained in:
2026-10-08 04:11:44 +02:00
parent e5580767a5
commit b336580040
31 changed files with 756 additions and 131 deletions
+12 -3
View File
@@ -25,9 +25,11 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.0",
"@nestjs/websockets": "^10.4.22",
"@prisma/client": "^5.10.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
@@ -39,6 +41,7 @@
"prisma": "^5.10.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"uuid": "^9.0.0"
@@ -60,17 +63,23 @@
"ts-loader": "^9.5.1"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@kooltap/shared$": "<rootDir>/../../packages/shared/src"
"^@kooltap/shared$": "<rootDir>/../../../packages/shared/src"
}
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PlannedTab" ADD COLUMN "deliveredAt" TIMESTAMP(3);
+2
View File
@@ -152,6 +152,8 @@ model PlannedTab {
email String?
position Int @default(0)
createdAt DateTime @default(now())
// Set once the terminal reports an open tab with this name; it is then no longer sent to devices
deliveredAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
+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 { RealtimeModule } from './realtime/realtime.module';
import { HealthController } from './health.controller';
@Module({
@@ -19,6 +20,7 @@ import { HealthController } from './health.controller';
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
ScheduleModule.forRoot(),
PrismaModule,
RealtimeModule,
AuthModule,
VenuesModule,
DevicesModule,
+6
View File
@@ -5,6 +5,7 @@ import * as crypto from 'crypto';
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 { TOKEN_EXPIRY_DAYS, TOKEN_BYTE_LENGTH, TOKEN_PREFIX_LENGTH } from '@kooltap/shared';
@Injectable()
@@ -13,6 +14,7 @@ export class AuthService {
private prisma: PrismaService,
private jwtService: JwtService,
private auditService: AuditService,
private realtime: RealtimeService,
) {}
async login(email: string, password: string) {
@@ -172,6 +174,8 @@ export class AuthService {
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
this.realtime.notify(venueId, 'device');
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
}
@@ -199,6 +203,8 @@ export class AuthService {
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
});
this.realtime.notify(venueId, 'users');
return user;
}
@@ -7,6 +7,7 @@ import {
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 { CreateCommandDto } from './dto/create-command.dto';
import { COMMAND_EXPIRY_HOURS, DEVICE_OFFLINE_THRESHOLD_MS } from '@kooltap/shared';
@@ -15,6 +16,7 @@ export class CommandsService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
) {}
async create(venueId: string, dto: CreateCommandDto, user: any) {
@@ -130,6 +132,8 @@ export class CommandsService {
};
});
this.realtime.notify(venueId, 'commands');
return result;
}
+5 -1
View File
@@ -9,7 +9,7 @@ import {
HttpStatus,
Req,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { SkipThrottle, Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service';
@@ -37,7 +37,10 @@ export class DevicesController {
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
}
// Authenticated by the device token; the till must never be rate-limited out by other
// traffic from the venue's shared IP
@Post(':deviceId/sync')
@SkipThrottle()
@UseGuards(DeviceAuthGuard)
@ApiOperation({ summary: 'Sync device state with server' })
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
@@ -46,6 +49,7 @@ export class DevicesController {
}
@Post(':deviceId/commands/:commandId/ack')
@SkipThrottle()
@UseGuards(DeviceAuthGuard)
@ApiOperation({ summary: 'Acknowledge a command result' })
@ApiResponse({ status: 200, description: 'Command acknowledged' })
@@ -0,0 +1,103 @@
import { DevicesService } from './devices.service';
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
const tx = {
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn() },
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() },
};
const prisma = {
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
tabOwnership: { findMany: jest.fn().mockResolvedValue([]) },
$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' }]) },
};
const audit = { log: jest.fn() };
const realtime = { notify: jest.fn() };
const service = new DevicesService(prisma as any, audit as any, realtime as any);
return { service, prisma, tx, realtime };
}
const device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
describe('DevicesService.sync planned customers', () => {
it('marks planned customers delivered once the device reports a tab with their name', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', 'Piet')] });
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
where: { venueId: 'v1', deliveredAt: null, customerName: { in: ['Jan', 'Piet'] } },
data: { deliveredAt: expect.any(Date) },
});
});
it('only sends customers that have not been delivered yet', async () => {
const { service, prisma } = createService();
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.plannedTab.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { venueId: 'v1', deliveredAt: null } }),
);
expect(result.plannedTabNames).toEqual(['Els']);
});
it('does not mark anything delivered when the device reports no tabs', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(tx.plannedTab.updateMany).not.toHaveBeenCalled();
});
});
describe('DevicesService.sync live update signals', () => {
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
it('sends nothing when the sync changed nothing', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
expect(realtime.notify).not.toHaveBeenCalled();
});
it('signals "tabs" when a tab changed', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, version: 3, totalInCents: 900, itemCount: 2 }] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
});
it('signals "tabs" when a tab disappeared', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
});
it('signals delivered customers and expired commands', async () => {
const { service, realtime } = createService({ previous: [janSnapshot], delivered: 1, expired: 2 });
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'planned-tabs', 'commands');
});
it('signals "device" when a terminal comes back online', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync({ ...device, lastSeenAt: new Date(Date.now() - 10 * 60 * 1000) }, { deviceId: 'd1', openTabs: [jan] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
});
});
+46 -3
View File
@@ -7,6 +7,8 @@ import {
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
import { SyncDto } from './dto/sync.dto';
import { AckCommandDto } from './dto/ack-command.dto';
@@ -15,6 +17,7 @@ export class DevicesService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
) {}
async sync(device: any, dto: SyncDto) {
@@ -29,6 +32,21 @@ export class DevicesService {
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
// Devices sync often, so dashboards are only told about what actually changed
const previous = await this.prisma.openTabSnapshot.findMany({
where: { deviceId: device.id },
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true },
});
const previousById = new Map(previous.map((s) => [s.tabId, s]));
const tabsChanged =
previous.length !== openTabs.length ||
openTabs.some((t) => {
const p = previousById.get(t.id);
return !p || p.customerName !== t.customerName || p.version !== t.version
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount;
});
let plannedDelivered = 0;
// Ownership changes are detected up front so audit logging stays outside the transaction
const takenOver = await this.prisma.tabOwnership.findMany({
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
@@ -90,6 +108,19 @@ export class DevicesService {
});
}
// A planned customer counts as delivered once the device reports a tab with that exact name
// (the terminal matches names exactly when creating tabs)
if (openTabs.length > 0) {
({ count: plannedDelivered } = await tx.plannedTab.updateMany({
where: {
venueId: device.venueId,
deliveredAt: null,
customerName: { in: openTabs.map((t) => t.customerName) },
},
data: { deliveredAt: now },
}));
}
await tx.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
@@ -97,7 +128,7 @@ export class DevicesService {
}, { timeout: 15000 });
// Expire old commands
await this.prisma.remoteCommand.updateMany({
const expired = await this.prisma.remoteCommand.updateMany({
where: {
deviceId: device.id,
status: 'pending',
@@ -131,13 +162,21 @@ export class DevicesService {
orderBy: { createdAt: 'asc' },
});
// Fetch planned tab names for this venue
// Only customers the terminal has not opened a tab for yet
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId: device.venueId },
where: { venueId: device.venueId, deliveredAt: null },
orderBy: { position: 'asc' },
select: { customerName: true },
});
const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
// 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);
return {
cursor,
commands: commands.map((c) => ({
@@ -198,6 +237,8 @@ export class DevicesService {
{ commandId, status: dto.status, result: dto.result },
);
this.realtime.notify(device.venueId, 'commands', 'tabs');
return { ok: true };
}
@@ -229,6 +270,8 @@ export class DevicesService {
await this.auditService.log(venueId, 'system', deviceId, 'device_revoked', { deviceName: device.name });
this.realtime.notify(venueId, 'device', 'commands');
return { ok: true };
}
}
+9 -1
View File
@@ -1,13 +1,17 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class CommandExpiryJob {
private readonly logger = new Logger(CommandExpiryJob.name);
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {}
@Cron(CronExpression.EVERY_HOUR)
async handleExpiredCommands() {
@@ -48,6 +52,10 @@ export class CommandExpiryJob {
});
}
for (const venueId of new Set(expiredCommands.map((c) => c.venueId))) {
this.realtime.notify(venueId, 'commands');
}
this.logger.log(`Expired ${expiredCommands.length} commands`);
}
}
@@ -24,7 +24,7 @@ function createService(initial: Omit<Row, 'venueId'>[]) {
},
};
const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) };
const service = new PlannedTabsService(prisma as any);
const service = new PlannedTabsService(prisma as any, { notify: jest.fn() } as any);
return { service, rows: () => rows };
}
@@ -1,16 +1,20 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class PlannedTabsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {}
async findAll(venueId: string) {
const tabs = await this.prisma.plannedTab.findMany({
where: { venueId },
orderBy: { position: 'asc' },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
select: { id: true, customerName: true, email: true, position: true, createdAt: true, deliveredAt: true },
});
return { plannedTabs: tabs };
}
@@ -44,6 +48,7 @@ export class PlannedTabsService {
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
this.realtime.notify(venueId, 'planned-tabs');
return tab;
}
@@ -67,6 +72,7 @@ export class PlannedTabsService {
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
return result;
}
@@ -86,7 +92,7 @@ export class PlannedTabsService {
incoming.set(key, { customerName, email: entry.email || previous?.email });
}
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
const existing = await tx.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' } });
const existingByKey = new Map(existing.map((t) => [t.customerName.toLowerCase(), t]));
let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
@@ -120,6 +126,9 @@ export class PlannedTabsService {
return { created, updated, unchanged, removed };
}, { timeout: 30000 });
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
return result;
}
async remove(venueId: string, id: string) {
@@ -127,6 +136,7 @@ export class PlannedTabsService {
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
await this.prisma.plannedTab.delete({ where: { id } });
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
return { ok: true };
}
}
+35
View File
@@ -0,0 +1,35 @@
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayInit, WebSocketGateway } from '@nestjs/websockets';
import { Namespace, Socket } from 'socket.io';
import { RealtimeService, venueRoom } from './realtime.service';
@WebSocketGateway({
namespace: '/realtime',
cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:3000', credentials: true },
})
export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
constructor(
private jwtService: JwtService,
private realtime: RealtimeService,
) {}
afterInit(namespace: Namespace) {
this.realtime.attach(namespace);
// Authenticate during the handshake so rejected clients never count as connected;
// the dashboard refreshes its access token on `unauthorized` and reconnects
namespace.use(async (socket, next) => {
try {
const payload = await this.jwtService.verifyAsync(String(socket.handshake.auth?.token ?? ''));
socket.data.venueId = payload.venueId;
next();
} catch {
next(new Error('unauthorized'));
}
});
}
async handleConnection(client: Socket) {
await client.join(venueRoom(client.data.venueId));
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { getJwtSecret } from '../auth/jwt-secret';
import { RealtimeGateway } from './realtime.gateway';
import { RealtimeService } from './realtime.service';
@Global()
@Module({
imports: [JwtModule.register({ secret: getJwtSecret() })],
providers: [RealtimeGateway, RealtimeService],
exports: [RealtimeService],
})
export class RealtimeModule {}
+24
View File
@@ -0,0 +1,24 @@
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 const venueRoom = (venueId: string) => `venue:${venueId}`;
/**
* Tells connected dashboards of a venue that data changed. Events only carry topic names,
* never data, so a socket can't see anything its owner couldn't fetch through the API.
*/
@Injectable()
export class RealtimeService {
private namespace: Namespace | null = null;
attach(namespace: Namespace) {
this.namespace = namespace;
}
notify(venueId: string, ...topics: RealtimeTopic[]) {
this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics });
}
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
// Tabs stay open per customer (closing only settles them), so a customer can be reminded again
// once this many days have passed since their last reminder.
@@ -19,7 +20,10 @@ export class RemindersService {
private resendApiKey: string;
private fromEmail: string;
constructor(private prisma: PrismaService) {
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {
this.resendApiKey = process.env.RESEND_API_KEY || '';
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
}
@@ -137,6 +141,8 @@ export class RemindersService {
}
}
if (sent > 0) this.realtime.notify(venueId, 'reminders');
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
}
+9
View File
@@ -22,6 +22,13 @@ export class TabsService {
});
const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s]));
// Customer details (email for reminders) live on the planned customer with the same name
const planned = await this.prisma.plannedTab.findMany({
where: { venueId },
select: { id: true, customerName: true, email: true },
});
const plannedByName = new Map(planned.map((p) => [p.customerName, p]));
const tabs = [];
for (const ownership of ownerships) {
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
@@ -38,6 +45,8 @@ export class TabsService {
deviceLastSeenAt: ownership.device.lastSeenAt,
deviceRevokedAt: ownership.device.revokedAt,
lastReportedAt: snapshot.reportedAt,
plannedTabId: plannedByName.get(snapshot.customerName)?.id ?? null,
email: plannedByName.get(snapshot.customerName)?.email ?? null,
});
}
}
+1
View File
@@ -31,6 +31,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.0",
"socket.io-client": "^4.8.4",
"tailwind-merge": "^2.2.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.22.4"
@@ -15,6 +15,7 @@ import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { formatRelativeTime, formatDateTime } from '@/lib/utils';
import { Filter } from 'lucide-react';
import { useRealtime } from '@/lib/realtime';
const statusFilters = [
{ value: '', label: 'All' },
@@ -26,6 +27,8 @@ const statusFilters = [
];
export default function CommandsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const router = useRouter();
const [venueId, setVenueId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState('');
@@ -49,7 +52,7 @@ export default function CommandsPage() {
queryFn: () =>
api(`/venues/${venueId}/commands?limit=${limit}&offset=${page * limit}${statusFilter ? `&status=${statusFilter}` : ''}`),
enabled: !!venueId,
refetchInterval: 10000,
refetchInterval: live ? false : 10000,
});
const commands = data?.commands || [];
+98 -83
View File
@@ -20,6 +20,7 @@ import { useLang } from '@/lib/i18n';
import { LanguageSelector } from '@/components/language-selector';
import { authApi, api } from '@/lib/api/client';
import { toast } from '@/components/ui/toast';
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
const navItems = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
@@ -78,91 +79,105 @@ export default function DashboardLayout({
}
return (
<div className="min-h-screen bg-background">
{/* Mobile sidebar toggle */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between h-14 px-4 border-b bg-background">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2">
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<span className="font-semibold">KoolTap</span>
<div className="w-9" />
</div>
{/* Sidebar overlay on mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed top-0 left-0 z-50 h-full w-64 border-r bg-card transition-transform lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex flex-col h-full">
<div className="p-6 border-b">
<h2 className="text-xl font-bold">KoolTap</h2>
{venue && (
<p className="text-sm text-muted-foreground mt-1">{venue.name}</p>
)}
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
if (item.adminOnly && user?.role !== 'admin') {
return null;
}
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent hover:text-accent-foreground',
)}
>
<Icon className="h-4 w-4" />
{t(item.key)}
</Link>
);
})}
</nav>
<div className="p-4 border-t">
<LanguageSelector />
{user && (
<div className="mb-3 text-sm">
<p className="font-medium truncate">{user.name}</p>
<p className="text-muted-foreground capitalize">{user.role}</p>
</div>
)}
<Button
variant="outline"
className="w-full justify-start gap-3"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
{t('nav.logout')}
</Button>
</div>
<RealtimeProvider>
<div className="min-h-screen bg-background">
{/* Mobile sidebar toggle */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between h-14 px-4 border-b bg-background">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2">
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<span className="font-semibold">KoolTap</span>
<div className="w-9" />
</div>
</aside>
{/* Main content */}
<main className="lg:pl-64 pt-14 lg:pt-0">
<div className="p-6 max-w-7xl mx-auto">{children}</div>
</main>
{/* Sidebar overlay on mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed top-0 left-0 z-50 h-full w-64 border-r bg-card transition-transform lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex flex-col h-full">
<div className="p-6 border-b">
<h2 className="text-xl font-bold">KoolTap</h2>
{venue && (
<p className="text-sm text-muted-foreground mt-1">{venue.name}</p>
)}
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
if (item.adminOnly && user?.role !== 'admin') {
return null;
}
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent hover:text-accent-foreground',
)}
>
<Icon className="h-4 w-4" />
{t(item.key)}
</Link>
);
})}
</nav>
<div className="p-4 border-t">
<LiveIndicator />
<LanguageSelector />
{user && (
<div className="mb-3 text-sm">
<p className="font-medium truncate">{user.name}</p>
<p className="text-muted-foreground capitalize">{user.role}</p>
</div>
)}
<Button
variant="outline"
className="w-full justify-start gap-3"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
{t('nav.logout')}
</Button>
</div>
</div>
</aside>
{/* Main content */}
<main className="lg:pl-64 pt-14 lg:pt-0">
<div className="p-6 max-w-7xl mx-auto">{children}</div>
</main>
</div>
</RealtimeProvider>
);
}
function LiveIndicator() {
const { connected } = useRealtime();
const { t } = useLang();
return (
<div className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
<span className={cn('h-2 w-2 rounded-full', connected ? 'bg-green-500' : 'bg-muted-foreground/40')} />
{connected ? t('realtime.live') : t('realtime.offline')}
</div>
);
}
+7 -3
View File
@@ -11,8 +11,11 @@ import { LoadingState } from '@/components/loading-state';
import { CommandStatusBadge } from '@/components/command-status-badge';
import { formatRelativeTime } from '@/lib/utils';
import { ClipboardList, Terminal, Monitor } from 'lucide-react';
import { useRealtime } from '@/lib/realtime';
export default function DashboardPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const router = useRouter();
const [venueId, setVenueId] = useState<string | null>(null);
@@ -32,13 +35,14 @@ export default function DashboardPage() {
queryKey: ['tabs', venueId],
queryFn: () => api(`/venues/${venueId}/tabs`),
enabled: !!venueId,
refetchInterval: 10000,
refetchInterval: live ? false : 10000,
});
const deviceQuery = useQuery({
queryKey: ['device', venueId],
queryFn: () => api(`/venues/${venueId}/devices`),
enabled: !!venueId,
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 30000,
});
@@ -46,7 +50,7 @@ export default function DashboardPage() {
queryKey: ['commands', venueId, 'recent'],
queryFn: () => api(`/venues/${venueId}/commands?limit=5`),
enabled: !!venueId,
refetchInterval: 15000,
refetchInterval: live ? false : 15000,
});
if (!venueId) return <LoadingState />;
@@ -60,7 +64,7 @@ export default function DashboardPage() {
return (
<div>
<PageHeader title="Overview" description="Real-time overview of your venue" />
<PageHeader title="Overview" description="" />
{isLoading ? (
<div className="grid gap-4 md:grid-cols-3 mb-8">
@@ -130,16 +130,12 @@ export default function RemindersPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4">
{t('reminders.candidates_desc')}
</p>
{candidatesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : candidatesQuery.error ? (
<p className="text-sm text-destructive">{t('common.error')}</p>
) : candidates.length === 0 ? (
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
<EmptyState title={t('reminders.no_candidates')} description='' />
) : (
<>
<div className="rounded-md border mb-4">
@@ -38,6 +38,7 @@ export default function SettingsPage() {
queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId!),
enabled: !!venueId,
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 15000,
});
+34 -9
View File
@@ -20,8 +20,11 @@ import { formatCents, formatRelativeTime } from '@/lib/utils';
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
export default function TabsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const router = useRouter();
const queryClient = useQueryClient();
const [venueId, setVenueId] = useState<string | null>(null);
@@ -47,22 +50,26 @@ export default function TabsPage() {
queryKey: ['tabs', venueId],
queryFn: () => api(`/venues/${venueId}/tabs`),
enabled: !!venueId,
refetchInterval: 10000,
refetchInterval: live ? false : 10000,
});
const commandsQuery = useQuery({
queryKey: ['commands', venueId, 'pending'],
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
enabled: !!venueId && pendingCommandIds.size > 0,
refetchInterval: 3000,
refetchInterval: live ? false : 3000,
});
const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId!),
enabled: !!venueId,
refetchInterval: live ? false : 10000,
});
// Tiles only show customers the terminal has not opened a tab for yet
const pendingPlanned = (plannedQuery.data?.plannedTabs || []).filter((pt: any) => !pt.deliveredAt);
useEffect(() => {
if (commandsQuery.data?.commands) {
for (const cmd of commandsQuery.data.commands) {
@@ -158,9 +165,6 @@ export default function TabsPage() {
</div>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
These names are synced to the terminal. It will create tabs for these customers automatically.
</p>
{userRole === 'admin' && (
<div className="flex gap-2 mb-3 flex-wrap">
<Input
@@ -184,10 +188,10 @@ export default function TabsPage() {
</div>
)}
<div className="flex flex-wrap gap-2">
{plannedQuery.data?.plannedTabs?.length === 0 && (
<p className="text-sm text-muted-foreground">No planned customers. Add names above and they'll sync to the terminal.</p>
{plannedQuery.data && pendingPlanned.length === 0 && (
<p className="text-sm text-muted-foreground">No customers waiting for the terminal.</p>
)}
{(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
{pendingPlanned.map((pt: any) => (
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
{pt.customerName}
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
@@ -213,13 +217,14 @@ export default function TabsPage() {
</Card>
{tabs.length === 0 ? (
<EmptyState title="No open tabs" description="Open tabs will appear here when the terminal syncs." />
<EmptyState title="No open tabs" description="" />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Email</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Items</TableHead>
<TableHead>Version</TableHead>
@@ -231,6 +236,26 @@ export default function TabsPage() {
{tabs.map((tab: any) => (
<TableRow key={tab.tabId}>
<TableCell className="font-medium">{tab.customerName}</TableCell>
<TableCell>
{tab.email ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
<span className="truncate max-w-[200px]">{tab.email}</span>
{userRole === 'admin' && tab.plannedTabId && (
<button
onClick={() => sendTestMutation.mutate(tab.plannedTabId)}
disabled={sendTestMutation.isPending}
className="hover:text-primary"
title="Send test email"
>
<Send className="h-3 w-3" />
</button>
)}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{tab.version}</TableCell>
-3
View File
@@ -36,7 +36,6 @@ export default function LoginPage() {
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">KoolTap</CardTitle>
<CardDescription>Remote Tab Control Dashboard</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -45,7 +44,6 @@ export default function LoginPage() {
<Input
id="email"
type="email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@@ -57,7 +55,6 @@ export default function LoginPage() {
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
+6 -2
View File
@@ -1,4 +1,4 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
interface ApiError {
code: string;
@@ -208,4 +208,8 @@ export const remindersApi = {
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
};
export { setTokens, clearTokens };
export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null {
return getTokens().accessToken ?? null;
}
+4
View File
@@ -11,6 +11,8 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.commands': 'Command History',
'nav.settings': 'Settings',
'nav.logout': 'Logout',
'realtime.live': 'Live updates on',
'realtime.offline': 'Live updates unavailable — refreshing periodically',
'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs',
@@ -168,6 +170,8 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.commands': 'Opdrachten',
'nav.settings': 'Instellingen',
'nav.logout': 'Uitloggen',
'realtime.live': 'Live updates aan',
'realtime.offline': 'Live updates niet beschikbaar — wordt periodiek vernieuwd',
'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs',
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { io } from 'socket.io-client';
import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client';
// Server topics mapped to the dashboard queries that show that data
const TOPIC_QUERIES: Record<string, string[][]> = {
tabs: [['tabs'], ['reminder-candidates']],
commands: [['commands']],
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
device: [['device']],
reminders: [['reminder-candidates'], ['reminder-history']],
users: [['users']],
};
const RealtimeContext = createContext({ connected: false });
export function RealtimeProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [connected, setConnected] = useState(false);
useEffect(() => {
const socket = io(`${new URL(API_URL).origin}/realtime`, {
// Read on every (re)connect so a refreshed token is used
auth: (cb) => cb({ token: getAccessToken() }),
transports: ['websocket', 'polling'],
});
let hasConnected = false;
let refreshing = false;
let rejections = 0;
socket.on('connect', () => {
setConnected(true);
rejections = 0;
// Changes may have been missed while disconnected
if (hasConnected) queryClient.invalidateQueries();
hasConnected = true;
});
socket.on('disconnect', () => setConnected(false));
socket.on('invalidate', ({ topics }: { topics: string[] }) => {
for (const topic of topics) {
for (const queryKey of TOPIC_QUERIES[topic] ?? []) {
queryClient.invalidateQueries({ queryKey });
}
}
});
// The access token expired: refresh it and reconnect (rejected handshakes are not retried
// automatically). If refreshing fails, the next API call sends the user to the login page.
socket.on('connect_error', async (error) => {
if (error.message !== 'unauthorized' || refreshing || ++rejections > 3) return;
refreshing = true;
const token = await refreshAccessToken();
refreshing = false;
if (token) socket.connect();
});
return () => {
socket.disconnect();
};
}, [queryClient]);
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
}
export function useRealtime() {
return useContext(RealtimeContext);
}
/** Polling interval to use only while live updates are unavailable. */
export function usePollingFallback(intervalMs: number): number | false {
const { connected } = useRealtime();
return connected ? false : intervalMs;
}
File diff suppressed because one or more lines are too long