Hi ${escapeHtml(candidate.customerName)},
+This is a friendly reminder that you have an open tab of €${(candidate.amountInCents / 100).toFixed(2)} (${candidate.itemCount} item${candidate.itemCount !== 1 ? 's' : ''}).
+Please settle your tab at the bar at your earliest convenience.
+Thank you!
`, + }), + }); + + 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: `Hi ${escapeHtml(tab.customerName)},
+This is a test email from KoolTab to verify that email reminders are working correctly.
+If you received this, the email configuration is working!
+This is a test — no action needed.
`, + }), + }); + + 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 }; + } +} \ No newline at end of file diff --git a/apps/api/src/tabs/tabs.controller.ts b/apps/api/src/tabs/tabs.controller.ts index e8a81aa..58ecc82 100644 --- a/apps/api/src/tabs/tabs.controller.ts +++ b/apps/api/src/tabs/tabs.controller.ts @@ -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) {} diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts index 46e19d5..8ecacc6 100644 --- a/apps/api/src/tabs/tabs.service.ts +++ b/apps/api/src/tabs/tabs.service.ts @@ -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({ diff --git a/apps/api/src/venues/dto/create-user.dto.ts b/apps/api/src/venues/dto/create-user.dto.ts new file mode 100644 index 0000000..a1008bb --- /dev/null +++ b/apps/api/src/venues/dto/create-user.dto.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/venues/venues.controller.ts b/apps/api/src/venues/venues.controller.ts index 0d79303..98dc1e4 100644 --- a/apps/api/src/venues/venues.controller.ts +++ b/apps/api/src/venues/venues.controller.ts @@ -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({ diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 9fab7a9..2895780 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -32,6 +32,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.49.0", "tailwind-merge": "^2.2.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index 76a0e83..815b740 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -12,18 +12,22 @@ import { LogOut, Menu, X, + Bell, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/cn'; +import { useLang } from '@/lib/i18n'; +import { LanguageSelector } from '@/components/language-selector'; import { authApi, api } from '@/lib/api/client'; import { toast } from '@/components/ui/toast'; const navItems = [ - { href: '/dashboard', label: 'Overview', icon: LayoutDashboard, neededRole: 'viewer' }, - { href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList, neededRole: 'viewer' }, - { href: '/dashboard/users', label: 'Users', icon: Monitor, neededRole: 'admin' }, - { href: '/dashboard/commands', label: 'Command History', icon: Terminal, neededRole: 'viewer' }, - { href: '/dashboard/settings', label: 'Settings', icon: Settings, neededRole: 'viewer' }, + { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false }, + { href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false }, + { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false }, + { href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true }, + { href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false }, + { href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false }, ]; export default function DashboardLayout({ @@ -36,6 +40,7 @@ export default function DashboardLayout({ const [sidebarOpen, setSidebarOpen] = useState(false); const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null); const [venue, setVenue] = useState<{ name: string } | null>(null); + const { t } = useLang(); useEffect(() => { // Check auth @@ -111,7 +116,7 @@ export default function DashboardLayout({ const Icon = item.icon; const active = pathname === item.href; - if (item.neededRole === 'admin' && user?.role !== 'admin') { + if (item.adminOnly && user?.role !== 'admin') { return null; } @@ -128,13 +133,14 @@ export default function DashboardLayout({ )} >{user.name}
@@ -147,7 +153,7 @@ export default function DashboardLayout({ onClick={handleLogout} >{t('reminders.history_empty')}
+ ) : ( ++ {t('reminders.candidates_desc')} +
+ + {candidatesQuery.isLoading ? ( +{t('common.loading')}
+ ) : candidatesQuery.error ? ( +{t('common.error')}
+ ) : candidates.length === 0 ? ( +These names are synced to the terminal. It will create tabs for these customers automatically.
{userRole === 'admin' && ( -