feat: add plannedTabs

This commit is contained in:
2026-09-20 12:20:55 +02:00
parent 4eb9bf7643
commit db3d8f30f0
28 changed files with 1172 additions and 782 deletions
+2
View File
@@ -10,6 +10,7 @@ import { TabsModule } from './tabs/tabs.module';
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 { HealthController } from './health.controller';
@Module({
@@ -24,6 +25,7 @@ import { HealthController } from './health.controller';
CommandsModule,
AuditModule,
JobsModule,
PlannedTabsModule,
],
controllers: [HealthController],
providers: [
+36
View File
@@ -132,6 +132,15 @@ export class AuthService {
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,
@@ -153,6 +162,33 @@ export class AuthService {
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
}
async createUser(venueId: string, email: string, password: string, name: string, role: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
if (!venue) {
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found. Your session may be stale — try logging out and back in.' });
}
const existingUser = await this.prisma.clientUser.findUnique({ where: { email } });
if (existingUser) {
throw new ConflictException({ code: 'email_exists', message: 'A user with this email already exists' });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await this.prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email,
passwordHash,
name,
role,
},
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
});
return user;
}
async validateDevice(deviceId: string, token: string): Promise<any> {
const hash = crypto.createHash('sha256').update(token).digest('hex');
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
+8
View File
@@ -142,6 +142,13 @@ export class DevicesService {
orderBy: { createdAt: 'asc' },
});
// Fetch planned tab names for this venue
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId: device.venueId },
orderBy: { position: 'asc' },
select: { customerName: true },
});
return {
cursor,
commands: commands.map((c) => ({
@@ -153,6 +160,7 @@ export class DevicesService {
requestedBy: c.requestedBy,
createdAt: c.createdAt,
})),
plannedTabNames: plannedTabs.map((t) => t.customerName),
};
}
@@ -0,0 +1,36 @@
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PlannedTabsService } from './planned-tabs.service';
@ApiTags('Planned Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('venues/:venueId/planned-tabs')
export class PlannedTabsController {
constructor(private service: PlannedTabsService) {}
@Get()
@ApiOperation({ summary: 'List planned tab names' })
async findAll(@Param('venueId') venueId: string) {
return this.service.findAll(venueId);
}
@Post()
@ApiOperation({ summary: 'Add a planned tab name' })
async create(@Param('venueId') venueId: string, @Body() dto: { customerName: string }) {
return this.service.create(venueId, dto.customerName);
}
@Put(':id')
@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);
}
@Delete(':id')
@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,10 @@
import { Module } from '@nestjs/common';
import { PlannedTabsController } from './planned-tabs.controller';
import { PlannedTabsService } from './planned-tabs.service';
@Module({
controllers: [PlannedTabsController],
providers: [PlannedTabsService],
exports: [PlannedTabsService],
})
export class PlannedTabsModule {}
@@ -0,0 +1,72 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class PlannedTabsService {
constructor(private prisma: PrismaService) {}
async findAll(venueId: string) {
const tabs = await this.prisma.plannedTab.findMany({
where: { venueId },
orderBy: { position: 'asc' },
select: { id: true, customerName: true, position: true, createdAt: true },
});
return { plannedTabs: tabs };
}
async create(venueId: string, customerName: string) {
const name = customerName.trim();
if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' });
// Check for duplicate
const existing = await this.prisma.plannedTab.findUnique({
where: { venueId_customerName: { venueId, customerName: name } },
});
if (existing) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
}
// Get next position
const last = await this.prisma.plannedTab.findFirst({
where: { venueId },
orderBy: { position: 'desc' },
});
const tab = await this.prisma.plannedTab.create({
data: {
id: uuidv4(),
venueId,
customerName: name,
position: (last?.position ?? -1) + 1,
},
select: { id: true, customerName: true, position: true, createdAt: true },
});
return tab;
}
async update(venueId: string, id: string, customerName: string) {
const name = customerName.trim();
if (!name) throw new NotFoundException({ 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 result = await this.prisma.plannedTab.update({
where: { id },
data: { customerName: name },
select: { id: true, customerName: true, position: true, createdAt: true },
});
return result;
}
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' });
await this.prisma.plannedTab.delete({ where: { id } });
return { ok: true };
}
}
+36 -15
View File
@@ -1,7 +1,10 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, UseGuards, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
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 { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
@ApiTags('Venues')
@@ -9,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service';
export class VenuesController {
constructor(
private venuesService: VenuesService,
private authService: AuthService,
private prisma: PrismaService,
) {}
@@ -26,24 +30,41 @@ export class VenuesController {
return this.venuesService.findOne(venueId);
}
@Get(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'List users for a venue' })
async findUsers(@Param('venueId') venueId: string) {
const users = await this.prisma.clientUser.findMany({
where: { venueId },
select: { id: true, email: true, name: true, role: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
return { users };
}
@Post(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, 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 },
) {
return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role);
}
@Get(':venueId/devices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List devices for a venue' })
@ApiOperation({ summary: 'Get venue device info' })
async findDevices(@Param('venueId') venueId: string) {
const devices = await this.prisma.device.findMany({
where: { venueId },
select: {
id: true,
name: true,
lastSeenAt: true,
revokedAt: true,
createdAt: true,
tokenExpiresAt: true,
tokenPrefix: true,
},
orderBy: { createdAt: 'desc' },
const device = await this.prisma.device.findFirst({
where: { venueId, revokedAt: null },
select: { id: true, name: true, lastSeenAt: true, createdAt: true },
});
return { devices };
return { device };
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { VenuesController } from './venues.controller';
import { VenuesService } from './venues.service';
@Module({
imports: [AuthModule],
controllers: [VenuesController],
providers: [VenuesService],
exports: [VenuesService],