feat: reminders, Excel customer import and security hardening

Features
- Payment reminders via Resend (candidates, send, history, test email)
- Bulk import of the customer list from Excel/CSV in the dashboard
- Dashboard i18n (en/nl) with language selector

Security
- Enforce venue isolation on all /venues/:venueId routes
- Require admin for planned-tab changes and pairing codes
- Rate-limit login and device registration; opt-in TRUST_PROXY
- Require JWT_SECRET in production; stop leaking internal errors
- Escape customer names in reminder emails

Sync and commands
- Remove tabs the terminal no longer reports; validate nested sync payload
- Return all pending commands on every sync (fixes missed-command race)
- First command ack wins; atomic registration code claim
- Reject commands for revoked devices, stale versions or duplicates
- Remind only unpaid balances, with a 7-day cooldown

Dashboard
- Share a single token refresh across concurrent 401s (random logouts)
- Keep the session alive for the refresh token lifetime

Deployment
- Replace `db push --accept-data-loss` with migrations (baselines legacy DBs)
- Set NODE_ENV=production; pass Resend and TRUST_PROXY env in prod compose
This commit is contained in:
2026-10-07 23:18:37 +02:00
parent 58eb8ddb8e
commit 8b1e38cb4d
56 changed files with 3168 additions and 376 deletions
+2
View File
@@ -11,6 +11,7 @@ import { CommandsModule } from './commands/commands.module';
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 { HealthController } from './health.controller';
@Module({
@@ -26,6 +27,7 @@ import { HealthController } from './health.controller';
AuditModule,
JobsModule,
PlannedTabsModule,
RemindersModule,
],
controllers: [HealthController],
providers: [
+8 -5
View File
@@ -1,7 +1,9 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Authentication')
@Controller('auth')
@@ -9,6 +11,7 @@ export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' })
async login(@Body() dto: LoginDto) {
@@ -18,15 +21,15 @@ export class AuthController {
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
async refresh(@Body('refreshToken') refreshToken: string) {
return this.authService.refresh(refreshToken);
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refresh(dto.refreshToken);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Logout and revoke refresh token' })
async logout(@Body('refreshToken') refreshToken: string) {
await this.authService.logout(refreshToken);
async logout(@Body() dto: RefreshTokenDto) {
await this.authService.logout(dto.refreshToken);
return { ok: true };
}
}
+2 -1
View File
@@ -5,12 +5,13 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { DeviceStrategy } from './strategies/device.strategy';
import { getJwtSecret } from './jwt-secret';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
secret: getJwtSecret(),
signOptions: { expiresIn: '24h' },
}),
],
+38 -25
View File
@@ -129,33 +129,46 @@ export class AuthService {
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
const deviceId = uuidv4();
// One device per venue — revoke any existing device
const existing = await this.prisma.device.findFirst({ where: { venueId, revokedAt: null } });
if (existing) {
await this.prisma.device.update({
where: { id: existing.id },
data: { revokedAt: new Date() },
});
}
await this.prisma.device.create({
data: {
id: deviceId,
venueId,
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
},
});
await this.prisma.registrationCode.update({
where: { id: code.id },
// Claim the code atomically so it can only ever be used once
const claimed = await this.prisma.registrationCode.updateMany({
where: { id: code.id, usedAt: null },
data: { usedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
let deviceId: string;
// One device per venue — update existing or create new
const existing = await this.prisma.device.findFirst({ where: { venueId } });
if (existing) {
deviceId = existing.id;
await this.prisma.device.update({
where: { id: existing.id },
data: {
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
revokedAt: null,
lastSeenAt: new Date(),
},
});
} else {
deviceId = uuidv4();
await this.prisma.device.create({
data: {
id: deviceId,
venueId,
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
},
});
}
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
@@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({ example: 'refresh-token-value' })
@IsString()
refreshToken: string;
}
+20
View File
@@ -0,0 +1,20 @@
import { Logger } from '@nestjs/common';
const DEV_FALLBACK_SECRET = 'dev-jwt-secret-change-in-production';
let warned = false;
export function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (secret) {
return secret;
}
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET must be set in production');
}
if (!warned) {
new Logger('Auth').warn('JWT_SECRET is not set — using an insecure development secret');
warned = true;
}
return DEV_FALLBACK_SECRET;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { getJwtSecret } from '../jwt-secret';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -8,7 +9,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
secretOrKey: getJwtSecret(),
});
}
+7 -5
View File
@@ -10,17 +10,18 @@ import {
HttpStatus,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { CommandsService } from './commands.service';
import { DevicesService } from '../devices/devices.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { CreateCommandDto } from './dto/create-command.dto';
@ApiTags('Commands')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/commands')
export class CommandsController {
constructor(
@@ -51,15 +52,16 @@ export class CommandsController {
) {
return this.commandsService.findAll(venueId, {
status,
limit: limit ? parseInt(limit, 10) : 50,
offset: offset ? parseInt(offset, 10) : 0,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
}
@Post('devices/:deviceId/revoke')
@UseGuards(RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Revoke a device' })
@ApiOperation({ summary: 'Revoke a device (admin only)' })
@ApiResponse({ status: 200, description: 'Device revoked' })
async revoke(
@Param('venueId') venueId: string,
@Param('deviceId') deviceId: string,
+28
View File
@@ -2,6 +2,7 @@ import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
@@ -54,6 +55,33 @@ export class CommandsService {
});
}
if (device.revokedAt) {
throw new ConflictException({
code: 'device_revoked',
message: 'The device owning this tab has been revoked',
});
}
const snapshot = await tx.openTabSnapshot.findUnique({
where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } },
});
if (snapshot && snapshot.version !== dto.expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const alreadyPending = await tx.remoteCommand.findFirst({
where: { venueId, targetTabId: dto.tabId, status: 'pending' },
});
if (alreadyPending) {
throw new ConflictException({
code: 'command_already_pending',
message: 'A command for this tab is already pending',
});
}
const now = new Date();
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
@@ -1,8 +1,10 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
@@ -31,8 +33,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
code = resp.code || this.statusToCode(status);
}
}
} else if (exception instanceof Error) {
message = exception.message;
} else {
// Never leak internal error details (e.g. database errors) to clients
const request = ctx.getRequest();
this.logger.error(
`Unhandled error on ${request?.method} ${request?.url}`,
exception instanceof Error ? exception.stack : String(exception),
);
}
response.status(status).json({
@@ -0,0 +1,28 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { VenueAccessGuard } from './venue-access.guard';
function contextFor(params: Record<string, string>, user?: { venueId: string }): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ params, user }) }),
} as unknown as ExecutionContext;
}
describe('VenueAccessGuard', () => {
const guard = new VenueAccessGuard();
it('allows routes without a venueId param', () => {
expect(guard.canActivate(contextFor({}, { venueId: 'a' }))).toBe(true);
});
it('allows access to the user\'s own venue', () => {
expect(guard.canActivate(contextFor({ venueId: 'a' }, { venueId: 'a' }))).toBe(true);
});
it('rejects access to another venue', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException);
});
it('rejects when no user is attached', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException);
});
});
@@ -0,0 +1,23 @@
import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common';
/**
* Ensures the authenticated user can only access the venue they belong to.
* Must run after JwtAuthGuard so that `request.user` is populated.
*/
@Injectable()
export class VenueAccessGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const venueId = request.params?.venueId;
if (!venueId) {
return true;
}
if (!request.user || request.user.venueId !== venueId) {
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
}
return true;
}
}
+11 -4
View File
@@ -9,7 +9,8 @@ import {
HttpStatus,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -29,7 +30,9 @@ export class DevicesController {
) {}
@Post('register')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({ summary: 'Register a new device' })
@ApiResponse({ status: 201, description: 'Device registered successfully' })
async register(@Body() dto: RegisterDeviceDto) {
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
}
@@ -37,13 +40,15 @@ export class DevicesController {
@Post(':deviceId/sync')
@UseGuards(DeviceAuthGuard)
@ApiOperation({ summary: 'Sync device state with server' })
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
return this.devicesService.sync(req.user, dto);
}
@Post(':deviceId/commands/:commandId/ack')
@UseGuards(DeviceAuthGuard)
@ApiOperation({ summary: 'Acknowledge a command' })
@ApiOperation({ summary: 'Acknowledge a command result' })
@ApiResponse({ status: 200, description: 'Command acknowledged' })
async ackCommand(
@Param('deviceId') deviceId: string,
@Param('commandId') commandId: string,
@@ -54,9 +59,11 @@ export class DevicesController {
}
@Get('generation-code')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Generate a registration code' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate a registration code for pairing' })
@ApiResponse({ status: 200, description: 'Registration code generated' })
async generateCode(@Req() req: any) {
const venueId = req.user.venueId;
return this.authService.generateRegistrationCode(venueId);
+81 -87
View File
@@ -26,68 +26,75 @@ export class DevicesService {
}
const now = new Date();
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
// Upsert snapshots and ownership
for (const tab of dto.openTabs || []) {
await this.prisma.openTabSnapshot.upsert({
where: {
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
},
create: {
deviceId: device.id,
tabId: tab.id,
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: now,
},
update: {
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: now,
},
});
// Check if tab was owned by another device
const existingOwnership = await this.prisma.tabOwnership.findUnique({
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
});
if (existingOwnership && existingOwnership.deviceId !== device.id) {
if (existingOwnership.lastReportedAt < now) {
await this.auditService.log(
device.venueId,
'device',
device.id,
'snapshot_conflict',
{ tabId: tab.id, previousDevice: existingOwnership.deviceId, newDevice: device.id },
);
}
}
await this.prisma.tabOwnership.upsert({
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
create: {
tabId: tab.id,
venueId: device.venueId,
deviceId: device.id,
lastReportedAt: now,
},
update: {
deviceId: device.id,
lastReportedAt: now,
},
});
// 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 } },
});
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
'device',
device.id,
'snapshot_conflict',
{ tabId: ownership.tabId, previousDevice: ownership.deviceId, newDevice: device.id },
);
}
// Update lastSeenAt
await this.prisma.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
});
await this.prisma.$transaction(async (tx) => {
// The device reports its full set of open tabs: anything it no longer reports has been closed
await tx.tabOwnership.deleteMany({
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
});
await tx.openTabSnapshot.deleteMany({
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
});
for (const tab of openTabs) {
await tx.openTabSnapshot.upsert({
where: {
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
},
create: {
deviceId: device.id,
tabId: tab.id,
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: now,
},
update: {
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: now,
},
});
await tx.tabOwnership.upsert({
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
create: {
tabId: tab.id,
venueId: device.venueId,
deviceId: device.id,
lastReportedAt: now,
},
update: {
deviceId: device.id,
lastReportedAt: now,
},
});
}
await tx.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
});
}, { timeout: 15000 });
// Expire old commands
await this.prisma.remoteCommand.updateMany({
@@ -99,33 +106,15 @@ export class DevicesService {
data: { status: 'expired' },
});
// Build cursor from timestamp
const cursorTimestamp = now.toISOString();
const tieBreaker = uuidv4().substring(0, 8);
const cursor = Buffer.from(`${cursorTimestamp}:${tieBreaker}`).toString('base64url');
// Build the Prisma where clause
const commandWhere: any = {
// The cursor is kept for client compatibility but no longer filters commands: every pending
// command is returned on each sync, so a command committed while a sync was in flight can never
// be skipped. Devices dedupe by command id and re-ack commands they already processed.
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
const commandWhere = {
deviceId: device.id,
status: 'pending',
};
// Parse cursor from request if provided — skip filter if cursor is missing or invalid
if (dto.cursor) {
try {
const decoded = Buffer.from(dto.cursor, 'base64url').toString('utf-8');
const parts = decoded.split(':');
if (parts.length >= 1) {
const parsed = new Date(parts[0]);
if (!isNaN(parsed.getTime())) {
commandWhere.createdAt = { gt: parsed };
}
}
} catch {
// Invalid cursor — ignore filter, return all pending commands
}
}
// Find pending commands for this device
const commands = await this.prisma.remoteCommand.findMany({
where: commandWhere,
@@ -182,13 +171,13 @@ export class DevicesService {
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' });
}
// Idempotent: if already acknowledged with same status, just return ok
if (command.status === dto.status && command.acknowledgedAt) {
return { ok: true };
// Idempotent: the first acknowledgement wins, retries and late duplicates are no-ops
if (command.acknowledgedAt) {
return { ok: true, status: command.status };
}
await this.prisma.remoteCommand.update({
where: { id: commandId },
const updated = await this.prisma.remoteCommand.updateMany({
where: { id: commandId, acknowledgedAt: null },
data: {
status: dto.status,
result: dto.result || null,
@@ -196,6 +185,11 @@ export class DevicesService {
},
});
if (updated.count === 0) {
// A concurrent ack got there first
return { ok: true };
}
await this.auditService.log(
device.venueId,
'device',
+6 -1
View File
@@ -1,9 +1,11 @@
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID } from 'class-validator';
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
class OpenTabDto {
@ApiProperty({ example: 'local-tab-uuid' })
@IsString()
@IsNotEmpty()
id: string;
@ApiProperty({ example: 'Jan' })
@@ -38,5 +40,8 @@ export class SyncDto {
@ApiProperty({ type: [OpenTabDto] })
@IsArray()
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => OpenTabDto)
openTabs: OpenTabDto[];
}
+11 -5
View File
@@ -1,26 +1,32 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { Response } from 'express';
import { PrismaService } from './prisma/prisma.service';
@ApiTags('Health')
@SkipThrottle()
@Controller('health')
export class HealthController {
constructor(private prisma: PrismaService) {}
@Get()
@ApiOperation({ summary: 'Health check' })
async check() {
@ApiResponse({ status: 200, description: 'Server is healthy' })
@ApiResponse({ status: 503, description: 'Database is unreachable' })
async check(@Res({ passthrough: true }) res: Response) {
let dbStatus = 'ok';
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
dbStatus = 'error';
res.status(HttpStatus.SERVICE_UNAVAILABLE);
}
return {
status: 'ok',
status: dbStatus === 'ok' ? 'ok' : 'degraded',
database: dbStatus,
timestamp: new Date().toISOString(),
};
}
}
}
+11 -1
View File
@@ -2,15 +2,25 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Bulk customer imports can exceed the 100kb default
app.useBodyParser('json', { limit: '1mb' });
app.setGlobalPrefix('api/v1');
// Behind a reverse proxy, trust X-Forwarded-For so rate limiting is per client instead of per proxy.
if (process.env.TRUST_PROXY) {
const value = process.env.TRUST_PROXY;
app.set('trust proxy', /^\d+$/.test(value) ? Number(value) : value === 'true' ? true : value);
}
app.use(helmet());
app.enableCors({
@@ -0,0 +1,17 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePlannedTabDto {
@ApiProperty({ example: 'Jan' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
@@ -0,0 +1,33 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength, IsArray, ArrayMaxSize, ValidateNested, IsIn } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportPlannedTabEntryDto {
@ApiProperty({ example: 'Jan Peeters' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
export class ImportPlannedTabsDto {
@ApiProperty({ type: [ImportPlannedTabEntryDto] })
@IsArray()
@ArrayMaxSize(5000)
@ValidateNested({ each: true })
@Type(() => ImportPlannedTabEntryDto)
entries: ImportPlannedTabEntryDto[];
@ApiProperty({
enum: ['merge', 'replace'],
description: 'merge adds new names and updates emails; replace also removes names that are not in the import',
})
@IsIn(['merge', 'replace'])
mode: 'merge' | 'replace';
}
@@ -0,0 +1,17 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UpdatePlannedTabDto {
@ApiProperty({ example: 'Jan' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
@@ -1,11 +1,17 @@
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { PlannedTabsService } from './planned-tabs.service';
import { CreatePlannedTabDto } from './dto/create-planned-tab.dto';
import { UpdatePlannedTabDto } from './dto/update-planned-tab.dto';
import { ImportPlannedTabsDto } from './dto/import-planned-tabs.dto';
@ApiTags('Planned Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Controller('venues/:venueId/planned-tabs')
export class PlannedTabsController {
constructor(private service: PlannedTabsService) {}
@@ -17,18 +23,29 @@ export class PlannedTabsController {
}
@Post()
@Roles('admin')
@ApiOperation({ summary: 'Add a planned tab name' })
async create(@Param('venueId') venueId: string, @Body() dto: { customerName: string }) {
return this.service.create(venueId, dto.customerName);
async create(@Param('venueId') venueId: string, @Body() dto: CreatePlannedTabDto) {
return this.service.create(venueId, dto.customerName, dto.email);
}
@Post('import')
@Roles('admin')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bulk import planned tab names (e.g. from an Excel file)' })
async import(@Param('venueId') venueId: string, @Body() dto: ImportPlannedTabsDto) {
return this.service.import(venueId, dto.entries, dto.mode);
}
@Put(':id')
@Roles('admin')
@ApiOperation({ summary: 'Rename a planned tab' })
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: { customerName: string }) {
return this.service.update(venueId, id, dto.customerName);
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: UpdatePlannedTabDto) {
return this.service.update(venueId, id, dto.customerName, dto.email);
}
@Delete(':id')
@Roles('admin')
@ApiOperation({ summary: 'Remove a planned tab' })
async remove(@Param('venueId') venueId: string, @Param('id') id: string) {
return this.service.remove(venueId, id);
@@ -0,0 +1,77 @@
import { PlannedTabsService } from './planned-tabs.service';
type Row = { id: string; venueId: string; customerName: string; email: string | null; position: number };
function createService(initial: Omit<Row, 'venueId'>[]) {
let rows: Row[] = initial.map((r) => ({ ...r, venueId: 'v1' }));
const tx = {
plannedTab: {
findMany: jest.fn(async () => [...rows].sort((a, b) => a.position - b.position)),
create: jest.fn(async ({ data }: { data: Row }) => {
rows.push(data);
return data;
}),
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial<Row> }) => {
const row = rows.find((r) => r.id === where.id)!;
Object.assign(row, data);
return row;
}),
deleteMany: jest.fn(async ({ where }: { where: { id: { in: string[] } } }) => {
const before = rows.length;
rows = rows.filter((r) => !where.id.in.includes(r.id));
return { count: before - rows.length };
}),
},
};
const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) };
const service = new PlannedTabsService(prisma as any);
return { service, rows: () => rows };
}
describe('PlannedTabsService.import', () => {
it('adds new names, keeps existing spelling and only fills in emails', async () => {
const { service, rows } = createService([
{ id: 'a', customerName: 'Jan Peeters', email: null, position: 0 },
{ id: 'b', customerName: 'Els', email: 'els@example.com', position: 1 },
]);
const result = await service.import('v1', [
{ customerName: ' jan peeters ', email: 'jan@example.com' },
{ customerName: 'Els' },
{ customerName: 'Piet' },
], 'merge');
expect(result).toEqual({ created: 1, updated: 1, unchanged: 1, removed: 0 });
expect(rows().find((r) => r.id === 'a')).toMatchObject({ customerName: 'Jan Peeters', email: 'jan@example.com' });
expect(rows().find((r) => r.id === 'b')?.email).toBe('els@example.com');
expect(rows().find((r) => r.customerName === 'Piet')?.position).toBe(2);
});
it('collapses duplicate names within the import', async () => {
const { service, rows } = createService([]);
const result = await service.import('v1', [
{ customerName: 'Piet', email: 'piet@example.com' },
{ customerName: 'PIET' },
], 'merge');
expect(result.created).toBe(1);
expect(rows()).toHaveLength(1);
expect(rows()[0].email).toBe('piet@example.com');
});
it('removes names missing from the import only in replace mode', async () => {
const initial = [
{ id: 'a', customerName: 'Jan', email: null, position: 0 },
{ id: 'b', customerName: 'Els', email: null, position: 1 },
];
const merge = createService(initial);
expect((await merge.service.import('v1', [{ customerName: 'Jan' }], 'merge')).removed).toBe(0);
expect(merge.rows()).toHaveLength(2);
const replace = createService(initial);
expect((await replace.service.import('v1', [{ customerName: 'jan' }], 'replace')).removed).toBe(1);
expect(replace.rows().map((r) => r.customerName)).toEqual(['Jan']);
});
});
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { v4 as uuidv4 } from 'uuid';
@@ -10,14 +10,14 @@ export class PlannedTabsService {
const tabs = await this.prisma.plannedTab.findMany({
where: { venueId },
orderBy: { position: 'asc' },
select: { id: true, customerName: true, position: true, createdAt: true },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
return { plannedTabs: tabs };
}
async create(venueId: string, customerName: string) {
async create(venueId: string, customerName: string, email?: string) {
const name = customerName.trim();
if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' });
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
// Check for duplicate
const existing = await this.prisma.plannedTab.findUnique({
@@ -38,30 +38,90 @@ export class PlannedTabsService {
id: uuidv4(),
venueId,
customerName: name,
email: email?.trim() || null,
position: (last?.position ?? -1) + 1,
},
select: { id: true, customerName: true, position: true, createdAt: true },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
return tab;
}
async update(venueId: string, id: string, customerName: string) {
async update(venueId: string, id: string, customerName: string, email?: string) {
const name = customerName.trim();
if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' });
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
const duplicate = await this.prisma.plannedTab.findUnique({
where: { venueId_customerName: { venueId, customerName: name } },
});
if (duplicate && duplicate.id !== id) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
}
const result = await this.prisma.plannedTab.update({
where: { id },
data: { customerName: name },
select: { id: true, customerName: true, position: true, createdAt: true },
data: { customerName: name, email: email?.trim() || null },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
return result;
}
/**
* Bulk import of the customer list (e.g. from the client's Excel file).
* Names match case-insensitively and existing spelling is kept, because the terminal matches
* planned names to tabs exactly and a respelled name would open a second tab.
* A missing email in the import never clears an existing one.
*/
async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') {
const incoming = new Map<string, { customerName: string; email?: string }>();
for (const entry of entries) {
const customerName = entry.customerName.trim().replace(/\s+/g, ' ');
if (!customerName) continue;
const key = customerName.toLowerCase();
const previous = incoming.get(key);
incoming.set(key, { customerName, email: entry.email || previous?.email });
}
return 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;
let created = 0;
let updated = 0;
let unchanged = 0;
let removed = 0;
for (const [key, entry] of incoming) {
const current = existingByKey.get(key);
if (!current) {
await tx.plannedTab.create({
data: { id: uuidv4(), venueId, customerName: entry.customerName, email: entry.email ?? null, position: nextPosition++ },
});
created++;
} else if (entry.email && entry.email !== current.email) {
await tx.plannedTab.update({ where: { id: current.id }, data: { email: entry.email } });
updated++;
} else {
unchanged++;
}
}
if (mode === 'replace') {
const toRemove = existing.filter((t) => !incoming.has(t.customerName.toLowerCase())).map((t) => t.id);
if (toRemove.length > 0) {
removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count;
}
}
return { created, updated, unchanged, removed };
}, { timeout: 30000 });
}
async remove(venueId: string, id: string) {
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
@@ -0,0 +1,51 @@
import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RemindersService } from './reminders.service';
@ApiTags('Reminders')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/reminders')
export class RemindersController {
constructor(private service: RemindersService) {}
@Get('candidates')
@ApiOperation({ summary: 'List tabs eligible for payment reminders' })
@ApiResponse({ status: 200, description: 'List of eligible tabs with customer email and amount' })
async listCandidates(@Param('venueId') venueId: string) {
return this.service.listCandidates(venueId);
}
@Post('send')
@ApiOperation({ summary: 'Send payment reminders via Resend' })
@ApiResponse({ status: 201, description: 'Reminders sent' })
@ApiResponse({ status: 400, description: 'Resend not configured' })
async send(@Param('venueId') venueId: string, @Req() req: any) {
return this.service.sendReminders(venueId, req.user?.email || 'unknown');
}
@Get()
@ApiOperation({ summary: 'Get reminder history' })
@ApiResponse({ status: 200, description: 'List of previously sent reminders' })
async history(@Param('venueId') venueId: string) {
return this.service.history(venueId);
}
@Post('test/:plannedTabId')
@UseGuards(RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Send a test email to a planned customer (admin only)' })
@ApiResponse({ status: 201, description: 'Test email sent' })
@ApiResponse({ status: 400, description: 'Resend not configured or customer has no email' })
@ApiResponse({ status: 404, description: 'Planned customer not found' })
async sendTest(
@Param('venueId') venueId: string,
@Param('plannedTabId') plannedTabId: string,
) {
return this.service.sendTestEmail(venueId, plannedTabId);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RemindersController } from './reminders.controller';
import { RemindersService } from './reminders.service';
@Module({
controllers: [RemindersController],
providers: [RemindersService],
})
export class RemindersModule {}
+204
View File
@@ -0,0 +1,204 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.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.
const REMINDER_COOLDOWN_DAYS = 7;
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@Injectable()
export class RemindersService {
private resendApiKey: string;
private fromEmail: string;
constructor(private prisma: PrismaService) {
this.resendApiKey = process.env.RESEND_API_KEY || '';
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTab <onboarding@resend.dev>';
}
async listCandidates(venueId: string) {
const ownerships = await this.prisma.tabOwnership.findMany({
where: { venueId },
});
// Only the owning device's snapshot is authoritative for a tab
const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
});
const latestByTab = new Map(snapshots.map((s) => [s.tabId, s]));
const emailMap = new Map<string, string>();
try {
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId, email: { not: null } },
select: { customerName: true, email: true },
});
for (const pt of plannedTabs) {
if (pt.email) emailMap.set(pt.customerName.toLowerCase(), pt.email);
}
} catch {
// email column may not exist yet if migration hasn't been applied
}
let sentTabIds = new Set<string>();
try {
const alreadySent = await this.prisma.tabReminder.findMany({
where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
select: { tabId: true },
});
sentTabIds = new Set(alreadySent.map((r) => r.tabId));
} catch {
// Table may not exist yet if migration hasn't been applied
}
const candidates = [];
for (const snap of latestByTab.values()) {
if (snap.totalInCents <= 0) continue;
if (sentTabIds.has(snap.tabId)) continue;
const email = emailMap.get(snap.customerName.toLowerCase());
if (!email) continue;
candidates.push({
tabId: snap.tabId,
customerName: snap.customerName,
email,
amountInCents: snap.totalInCents,
itemCount: snap.itemCount,
});
}
return { candidates };
}
async sendReminders(venueId: string, requestorEmail: string) {
const { candidates } = await this.listCandidates(venueId);
if (candidates.length === 0) {
return { sent: 0, message: 'No candidates to remind' };
}
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
let sent = 0;
const errors: { customerName: string; error: string }[] = [];
for (const candidate of candidates) {
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
},
body: JSON.stringify({
from: this.fromEmail,
to: candidate.email,
subject: `Payment reminder for your tab at the bar`,
html: `<p>Hi ${escapeHtml(candidate.customerName)},</p>
<p>This is a friendly reminder that you have an open tab of <strong>&euro;${(candidate.amountInCents / 100).toFixed(2)}</strong> (${candidate.itemCount} item${candidate.itemCount !== 1 ? 's' : ''}).</p>
<p>Please settle your tab at the bar at your earliest convenience.</p>
<p>Thank you!</p>`,
}),
});
if (!res.ok) {
const errBody = await res.text();
errors.push({ customerName: candidate.customerName, error: errBody });
continue;
}
await this.prisma.tabReminder.create({
data: {
venueId,
tabId: candidate.tabId,
customerName: candidate.customerName,
email: candidate.email,
amountInCents: candidate.amountInCents,
},
});
sent++;
} catch (err: any) {
errors.push({ customerName: candidate.customerName, error: err.message });
}
}
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
}
async history(venueId: string) {
try {
const reminders = await this.prisma.tabReminder.findMany({
where: { venueId },
orderBy: { sentAt: 'desc' },
take: 100,
});
return { reminders };
} catch {
return { reminders: [] };
}
}
async sendTestEmail(venueId: string, plannedTabId: string) {
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
const tab = await this.prisma.plannedTab.findFirst({
where: { id: plannedTabId, venueId },
});
if (!tab) {
throw new NotFoundException({ code: 'not_found', message: 'Planned customer not found' });
}
if (!tab.email) {
throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' });
}
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
},
body: JSON.stringify({
from: this.fromEmail,
to: tab.email,
subject: `Test email from KoolTab`,
html: `<p>Hi ${escapeHtml(tab.customerName)},</p>
<p>This is a test email from KoolTab to verify that email reminders are working correctly.</p>
<p>If you received this, the email configuration is working!</p>
<hr>
<p style="color: #888; font-size: 12px;">This is a test — no action needed.</p>`,
}),
});
if (!res.ok) {
const errBody = await res.text();
throw new BadRequestException({
code: 'email_failed',
message: `Failed to send test email: ${errBody}`,
});
}
return { ok: true, email: tab.email, customerName: tab.customerName };
}
}
+2 -1
View File
@@ -2,10 +2,11 @@ import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TabsService } from './tabs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
@ApiTags('Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/tabs')
export class TabsController {
constructor(private tabsService: TabsService) {}
+8 -4
View File
@@ -15,12 +15,16 @@ export class TabsService {
},
});
const snapshots = await this.prisma.openTabSnapshot.findMany({
where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
});
const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s]));
const tabs = [];
for (const ownership of ownerships) {
const snapshot = await this.prisma.openTabSnapshot.findFirst({
where: { tabId: ownership.tabId, deviceId: ownership.deviceId },
orderBy: { reportedAt: 'desc' },
});
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
if (snapshot) {
tabs.push({
@@ -0,0 +1,23 @@
import { IsString, IsEmail, IsIn, MinLength, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecurePass123!' })
@IsString()
@MinLength(6)
password: string;
@ApiProperty({ example: 'John Doe' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ example: 'viewer', enum: ['admin', 'viewer'] })
@IsString()
@IsIn(['admin', 'viewer'])
role: string;
}
+7 -5
View File
@@ -4,8 +4,10 @@ import { VenuesService } from './venues.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
@ApiTags('Venues')
@Controller('venues')
@@ -24,7 +26,7 @@ export class VenuesController {
@Get(':venueId')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@ApiOperation({ summary: 'Get venue by ID' })
async findOne(@Param('venueId') venueId: string) {
return this.venuesService.findOne(venueId);
@@ -32,7 +34,7 @@ export class VenuesController {
@Get(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'List users for a venue' })
async findUsers(@Param('venueId') venueId: string) {
@@ -46,19 +48,19 @@ export class VenuesController {
@Post(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Create a new user for the venue' })
async createUser(
@Param('venueId') venueId: string,
@Body() dto: { email: string; password: string; name: string; role: string },
@Body() dto: CreateUserDto,
) {
return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role);
}
@Get(':venueId/devices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@ApiOperation({ summary: 'Get venue device info' })
async findDevices(@Param('venueId') venueId: string) {
const device = await this.prisma.device.findFirst({