diff --git a/.env.example b/.env.example index fb67a59..bf495fd 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,7 @@ # KoolTap Remote Tab Control -DATABASE_URL="file:./dev.db" +DATABASE_URL="postgresql://kooltap:kooltap@localhost:5432/kooltap" JWT_SECRET="dev-jwt-secret-change-in-production" -JWT_REFRESH_SECRET="dev-refresh-secret-change-in-production" PORT=3001 CORS_ORIGIN="http://localhost:3000" diff --git a/.env.prod.example b/.env.prod.example index dad54b4..287996a 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -4,9 +4,8 @@ # PostgreSQL DB_PASSWORD=changeme -# JWT secrets - generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +# JWT secret - generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" JWT_SECRET=changeme -JWT_REFRESH_SECRET=changeme # URLs DASHBOARD_URL=http://localhost:3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b5f35c..6a8f9e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,41 +2,68 @@ name: CI on: push: - branches: [main] + branches: [master] pull_request: - branches: [main] + branches: [master] jobs: build-and-test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: kooltap + POSTGRES_USER: kooltap + POSTGRES_PASSWORD: kooltap + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U kooltap" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://kooltap:kooltap@localhost:5432/kooltap + JWT_SECRET: ci-secret + steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v4 with: - version: 8 + version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - - run: pnpm install + - run: pnpm install --frozen-lockfile - name: Generate Prisma client run: cd apps/api && npx prisma generate - - name: Lint - run: pnpm lint - continue-on-error: true + - name: Build shared package + run: pnpm --filter @kooltap/shared run build - name: TypeScript check run: pnpm typecheck - continue-on-error: true - - name: Run migrations - run: cd apps/api && npx prisma migrate dev --name ci-init + # Uses the still-empty database as shadow database, so it must run before the migrations + - name: Check schema matches migrations + run: cd apps/api && npx prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --shadow-database-url "$DATABASE_URL" --exit-code + + - name: Apply migrations + run: cd apps/api && npx prisma migrate deploy + + - name: Unit tests + run: pnpm test + + - name: E2E tests + run: pnpm test:e2e - name: Build - run: pnpm build \ No newline at end of file + run: pnpm build diff --git a/.gitignore b/.gitignore index 2e7e379..235dc66 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ dist/ *.db *.db-journal coverage/ -.turbo/ \ No newline at end of file +.turbo/ +*.tsbuildinfo +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 30cf57e..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/kooltab_backoffice.iml b/.idea/kooltab_backoffice.iml deleted file mode 100644 index c956989..0000000 --- a/.idea/kooltab_backoffice.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index d4c3a9d..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index f0d837a..47d80c6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A production-quality remote tab control system for venues. Allows bar staff to m ## Architecture - **Monorepo** managed with pnpm -- **NestJS** backend API with Prisma ORM (SQLite for dev, PostgreSQL-ready) +- **NestJS** backend API with Prisma ORM on PostgreSQL - **Next.js** dashboard with App Router, TanStack Query, and shadcn/ui - **Shared** TypeScript types package @@ -95,4 +95,4 @@ docker compose up ## Production Notes -See [docs/production.md](docs/production.md) for production deployment and SQLite → PostgreSQL migration guide. \ No newline at end of file +See [docs/production.md](docs/production.md) for production deployment. \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index e8e13f2..d8f611f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,7 +15,6 @@ "db:migrate": "prisma migrate dev", "db:seed": "ts-node prisma/seed.ts", "db:reset": "prisma migrate reset --force && ts-node prisma/seed.ts", - "db:use-sqlite": "cp prisma/schema.sqlite.prisma prisma/schema.prisma", "clean": "rimraf dist" }, "dependencies": { @@ -55,6 +54,7 @@ "@types/jest": "^29.5.11", "@types/node": "^20.11.0", "@types/passport-jwt": "^4.0.0", + "@types/supertest": "^6.0.3", "@types/uuid": "^9.0.7", "jest": "^29.7.0", "rimraf": "^5.0.5", diff --git a/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql b/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql new file mode 100644 index 0000000..786ced1 --- /dev/null +++ b/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql @@ -0,0 +1,27 @@ +-- PlannedTab: match customers case-insensitively +ALTER TABLE "PlannedTab" ADD COLUMN "customerNameKey" TEXT; + +UPDATE "PlannedTab" SET "customerNameKey" = lower(trim(regexp_replace("customerName", '\s+', ' ', 'g'))); + +-- Names that only differ in case or spacing keep the oldest row as the match; later ones get a unique key +UPDATE "PlannedTab" p SET "customerNameKey" = p."customerNameKey" || '#' || p."id" +WHERE EXISTS ( + SELECT 1 FROM "PlannedTab" o + WHERE o."venueId" = p."venueId" + AND o."customerNameKey" = p."customerNameKey" + AND (o."createdAt", o."id") < (p."createdAt", p."id") +); + +ALTER TABLE "PlannedTab" ALTER COLUMN "customerNameKey" SET NOT NULL; + +DROP INDEX "PlannedTab_venueId_customerName_key"; + +CREATE UNIQUE INDEX "PlannedTab_venueId_customerNameKey_key" ON "PlannedTab"("venueId", "customerNameKey"); + +-- RemoteCommand: keep who the tab belonged to, the tab itself disappears once closed +ALTER TABLE "RemoteCommand" ADD COLUMN "customerName" TEXT, +ADD COLUMN "amountInCents" INTEGER; + +UPDATE "RemoteCommand" c SET "customerName" = s."customerName", "amountInCents" = s."totalInCents" +FROM "OpenTabSnapshot" s +WHERE s."deviceId" = c."deviceId" AND s."tabId" = c."targetTabId"; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d8d9d65..1e56944 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -34,7 +34,7 @@ model Device { revokedAt DateTime? createdAt DateTime @default(now()) - venue Venue @relation(fields: [venueId], references: [id]) + venue Venue @relation(fields: [venueId], references: [id]) tabSnapshots OpenTabSnapshot[] tabOwnerships TabOwnership[] remoteCommands RemoteCommand[] @@ -57,12 +57,12 @@ model ClientUser { } model RegistrationCode { - id String @id @default(uuid()) + id String @id @default(uuid()) venueId String - code String @unique + code String @unique expiresAt DateTime usedAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) venue Venue @relation(fields: [venueId], references: [id]) @@ -106,6 +106,8 @@ model RemoteCommand { venueId String deviceId String targetTabId String + customerName String? + amountInCents Int? type String @default("close_tab") expectedVersion Int paymentMethod String @@ -138,44 +140,46 @@ model AuditLog { } model RefreshToken { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String - tokenHash String @unique + tokenHash String @unique expiresAt DateTime revokedAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([userId]) } model PlannedTab { - id String @id @default(uuid()) - venueId String - customerName String - email String? - position Int @default(0) - createdAt DateTime @default(now()) + id String @id @default(uuid()) + venueId String + customerName String + // Lowercased, whitespace-collapsed name: customers are matched on this everywhere + customerNameKey String + 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? + deliveredAt DateTime? venue Venue @relation(fields: [venueId], references: [id]) - @@unique([venueId, customerName]) + @@unique([venueId, customerNameKey]) @@index([venueId]) } model TabReminder { - id String @id @default(uuid()) - venueId String - tabId String - customerName String - email String - amountInCents Int - sentAt DateTime @default(now()) - status String @default("sent") + id String @id @default(uuid()) + venueId String + tabId String + customerName String + email String + amountInCents Int + sentAt DateTime @default(now()) + status String @default("sent") venue Venue @relation(fields: [venueId], references: [id]) @@index([venueId, sentAt]) @@index([venueId, tabId]) -} \ No newline at end of file +} diff --git a/apps/api/prisma/schema.sqlite.prisma b/apps/api/prisma/schema.sqlite.prisma deleted file mode 100644 index 27d7d2c..0000000 --- a/apps/api/prisma/schema.sqlite.prisma +++ /dev/null @@ -1,158 +0,0 @@ -datasource db { - provider = "sqlite" - url = env("DATABASE_URL") -} - -generator client { - provider = "prisma-client-js" -} - -model Venue { - id String @id @default(uuid()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - devices Device[] - clientUsers ClientUser[] - tabOwnerships TabOwnership[] - remoteCommands RemoteCommand[] - auditLogs AuditLog[] - regCodes RegistrationCode[] - plannedTabs PlannedTab[] -} - -model Device { - id String @id @default(uuid()) - venueId String @unique - name String - tokenHash String @unique - tokenPrefix String - tokenExpiresAt DateTime - lastSeenAt DateTime @default(now()) - revokedAt DateTime? - createdAt DateTime @default(now()) - - venue Venue @relation(fields: [venueId], references: [id]) - tabSnapshots OpenTabSnapshot[] - tabOwnerships TabOwnership[] - remoteCommands RemoteCommand[] - - @@index([venueId, revokedAt]) -} - -model ClientUser { - id String @id @default(uuid()) - venueId String - email String @unique - passwordHash String - name String - role String @default("viewer") - createdAt DateTime @default(now()) - - venue Venue @relation(fields: [venueId], references: [id]) - - @@index([venueId]) -} - -model RegistrationCode { - id String @id @default(uuid()) - venueId String - code String @unique - expiresAt DateTime - usedAt DateTime? - createdAt DateTime @default(now()) - - venue Venue @relation(fields: [venueId], references: [id]) - - @@index([venueId]) -} - -model OpenTabSnapshot { - id String @id @default(uuid()) - deviceId String - tabId String - customerName String - version Int - totalInCents Int - itemCount Int - reportedAt DateTime - - device Device @relation(fields: [deviceId], references: [id]) - - @@unique([deviceId, tabId]) - @@index([deviceId]) - @@index([tabId, reportedAt]) -} - -model TabOwnership { - tabId String - venueId String - deviceId String - lastReportedAt DateTime - - venue Venue @relation(fields: [venueId], references: [id]) - device Device @relation(fields: [deviceId], references: [id]) - - @@id([venueId, tabId]) -} - -model RemoteCommand { - id String @id @default(uuid()) - venueId String - deviceId String - targetTabId String - type String @default("close_tab") - expectedVersion Int - paymentMethod String - requestedBy String - status String @default("pending") - result String? - acknowledgedAt DateTime? - createdAt DateTime @default(now()) - expiresAt DateTime - - venue Venue @relation(fields: [venueId], references: [id]) - device Device @relation(fields: [deviceId], references: [id]) - - @@index([deviceId, status, createdAt]) - @@index([venueId, status, createdAt]) -} - -model AuditLog { - id String @id @default(uuid()) - venueId String - actorType String - actorId String - action String - details String? - createdAt DateTime @default(now()) - - venue Venue @relation(fields: [venueId], references: [id]) - - @@index([venueId, createdAt]) -} - -model RefreshToken { - id String @id @default(uuid()) - userId String - tokenHash String @unique - expiresAt DateTime - revokedAt DateTime? - createdAt DateTime @default(now()) - - @@index([userId]) -} - -model PlannedTab { - id String @id @default(uuid()) - venueId String - customerName String - position Int @default(0) - createdAt DateTime @default(now()) - - venue Venue @relation(fields: [venueId], references: [id]) - - @@unique([venueId, customerName]) - @@index([venueId]) -} \ No newline at end of file diff --git a/apps/api/prisma/setup.js b/apps/api/prisma/setup.js index c3dc9c8..a5734c8 100644 --- a/apps/api/prisma/setup.js +++ b/apps/api/prisma/setup.js @@ -1,60 +1,70 @@ const { PrismaClient } = require('@prisma/client'); const bcrypt = require('bcrypt'); const { v4: uuidv4 } = require('uuid'); -const crypto = require('crypto'); const prisma = new PrismaClient(); -const VENUE_NAME = process.env.SETUP_VENUE_NAME || process.argv[2] || 'My Venue'; -const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || process.argv[3] || 'admin@example.com'; -const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || process.argv[4] || 'admin123'; +const args = process.argv.slice(2).filter((arg) => arg !== '--force'); +const FORCE = process.argv.includes('--force'); +const VENUE_NAME = process.env.SETUP_VENUE_NAME || args[0] || 'My Venue'; +const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || args[1]; +const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || args[2]; async function main() { console.log('=== KoolTap Production Setup ===\n'); - // Wipe everything - await prisma.auditLog.deleteMany(); - await prisma.remoteCommand.deleteMany(); - await prisma.openTabSnapshot.deleteMany(); - await prisma.tabOwnership.deleteMany(); - await prisma.refreshToken.deleteMany(); - await prisma.registrationCode.deleteMany(); - await prisma.device.deleteMany(); - await prisma.clientUser.deleteMany(); - await prisma.venue.deleteMany(); - console.log('✓ Cleared existing data'); + const venues = await prisma.venue.count(); + if (venues > 0 && !FORCE) { + console.log(`The database already has ${venues} venue(s); nothing to do. Pass --force to wipe ALL data and start over.`); + return; + } + + if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + throw new Error('Usage: node prisma/setup.js "" [--force]'); + } + if (ADMIN_PASSWORD.length < 10) { + throw new Error('The admin password must be at least 10 characters'); + } - // Create venue const venueId = uuidv4(); - await prisma.venue.create({ data: { id: venueId, name: VENUE_NAME } }); - console.log(`✓ Created venue: ${VENUE_NAME}`); - - // Create admin user const hash = await bcrypt.hash(ADMIN_PASSWORD, 10); - await prisma.clientUser.create({ - data: { - id: uuidv4(), - venueId, - email: ADMIN_EMAIL, - passwordHash: hash, - name: 'Admin', - role: 'admin', - }, - }); - console.log(`✓ Created admin: ${ADMIN_EMAIL}`); - console.log('\n=== Setup complete ==='); + await prisma.$transaction(async (tx) => { + await tx.auditLog.deleteMany(); + await tx.tabReminder.deleteMany(); + await tx.plannedTab.deleteMany(); + await tx.remoteCommand.deleteMany(); + await tx.openTabSnapshot.deleteMany(); + await tx.tabOwnership.deleteMany(); + await tx.refreshToken.deleteMany(); + await tx.registrationCode.deleteMany(); + await tx.device.deleteMany(); + await tx.clientUser.deleteMany(); + await tx.venue.deleteMany(); + + await tx.venue.create({ data: { id: venueId, name: VENUE_NAME } }); + await tx.clientUser.create({ + data: { + id: uuidv4(), + venueId, + email: ADMIN_EMAIL, + passwordHash: hash, + name: 'Admin', + role: 'admin', + }, + }); + }); + + console.log('=== Setup complete ==='); console.log(` Venue: ${VENUE_NAME}`); console.log(` Venue ID: ${venueId}`); console.log(` Admin: ${ADMIN_EMAIL}`); - console.log(` Password: ${ADMIN_PASSWORD}`); - console.log('\nSave the Venue ID — the Flutter app needs it to find your venue.'); - console.log('Login at https://kooltap.com with the admin credentials above.'); - - await prisma.$disconnect(); + console.log('\nLog in to the dashboard with the admin credentials you provided.'); } -main().catch((err) => { - console.error('Setup failed:', err); - process.exit(1); -}); \ No newline at end of file +main() + .catch((err) => { + console.error('Setup failed:', err.message ?? err); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index ced1668..2a51db3 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -12,7 +12,7 @@ import { getJwtSecret } from './jwt-secret'; PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: getJwtSecret(), - signOptions: { expiresIn: '24h' }, + signOptions: { expiresIn: '15m' }, }), ], controllers: [AuthController], diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..1fce3e8 --- /dev/null +++ b/apps/api/src/auth/auth.service.spec.ts @@ -0,0 +1,72 @@ +import * as crypto from 'crypto'; +import * as bcrypt from 'bcrypt'; +import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; + +const hash = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); + +type StoredToken = { id: string; userId: string; tokenHash: string; expiresAt: Date; revokedAt: Date | null }; + +function createService() { + const tokens: StoredToken[] = []; + const user = { id: 'u1', email: 'admin@example.com', name: 'Admin', role: 'admin', venueId: 'v1', passwordHash: bcrypt.hashSync('correct-password', 4) }; + const prisma = { + clientUser: { + findUnique: jest.fn(async ({ where }: any) => (where.id === user.id || where.email === user.email ? user : null)), + }, + refreshToken: { + create: jest.fn(async ({ data }: any) => { + tokens.push({ id: `rt${tokens.length}`, revokedAt: null, ...data }); + }), + findUnique: jest.fn(async ({ where }: any) => tokens.find((t) => t.tokenHash === where.tokenHash) ?? null), + updateMany: jest.fn(async ({ where, data }: any) => { + const matches = tokens.filter((t) => + (where.id === undefined || t.id === where.id) + && (where.tokenHash === undefined || t.tokenHash === where.tokenHash) + && (where.revokedAt !== null || t.revokedAt === null)); + for (const t of matches) Object.assign(t, data); + return { count: matches.length }; + }), + }, + }; + const service = new AuthService(prisma as any, new JwtService({ secret: 'test' }), { log: jest.fn() } as any, { notify: jest.fn() } as any); + return { service, tokens }; +} + +describe('AuthService', () => { + it('rejects a wrong password', async () => { + const { service } = createService(); + + await expect(service.login('admin@example.com', 'wrong-password')).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rotates the refresh token on use', async () => { + const { service, tokens } = createService(); + const { refreshToken } = await service.login('admin@example.com', 'correct-password'); + + const refreshed = await service.refresh(refreshToken); + + expect(refreshed.refreshToken).not.toBe(refreshToken); + expect(tokens.find((t) => t.tokenHash === hash(refreshToken))?.revokedAt).toBeInstanceOf(Date); + }); + + it('accepts a refresh token only once, even for simultaneous requests', async () => { + const { service } = createService(); + const { refreshToken } = await service.login('admin@example.com', 'correct-password'); + + const results = await Promise.allSettled([service.refresh(refreshToken), service.refresh(refreshToken)]); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1); + }); + + it('rejects a refresh token after logout', async () => { + const { service } = createService(); + const { refreshToken } = await service.login('admin@example.com', 'correct-password'); + + await service.logout(refreshToken); + + await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index b51851b..c484514 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -64,6 +64,15 @@ export class AuthService { throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' }); } + // Revoke first and atomically: of two requests with the same token, only one gets new tokens + const claimed = await this.prisma.refreshToken.updateMany({ + where: { id: stored.id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (claimed.count === 0) { + throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' }); + } + const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } }); if (!user) { throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' }); @@ -76,11 +85,6 @@ export class AuthService { const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex'); const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - await this.prisma.refreshToken.update({ - where: { id: stored.id }, - data: { revokedAt: new Date() }, - }); - await this.prisma.refreshToken.create({ data: { userId: user.id, diff --git a/apps/api/src/commands/commands.service.spec.ts b/apps/api/src/commands/commands.service.spec.ts new file mode 100644 index 0000000..9e18930 --- /dev/null +++ b/apps/api/src/commands/commands.service.spec.ts @@ -0,0 +1,98 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { CommandsService } from './commands.service'; + +const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' }; +const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' }; + +function createService({ + ownership = { deviceId: 'd1' } as object | null, + device = { id: 'd1', revokedAt: null, lastSeenAt: new Date() } as object | null, + snapshot = { customerName: 'Jan', totalInCents: 2500, version: 3 } as object | null, + pending = null as object | null, +} = {}) { + const tx = { + tabOwnership: { findUnique: jest.fn().mockResolvedValue(ownership) }, + device: { findUnique: jest.fn().mockResolvedValue(device) }, + openTabSnapshot: { findUnique: jest.fn().mockResolvedValue(snapshot) }, + remoteCommand: { + findFirst: jest.fn().mockResolvedValue(pending), + create: jest.fn(async ({ data }) => data), + }, + auditLog: { create: jest.fn() }, + }; + const prisma = { $transaction: jest.fn((fn: (t: typeof tx) => unknown) => Promise.resolve().then(() => fn(tx))) }; + const realtime = { notify: jest.fn() }; + const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any); + return { service, prisma, tx, realtime }; +} + +describe('CommandsService.create', () => { + it('queues a close command with the customer and amount it was sent for', async () => { + const { service, prisma, tx, realtime } = createService(); + + const result = await service.create('v1', dto, user); + + expect(result.status).toBe('pending'); + expect(tx.remoteCommand.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + venueId: 'v1', + deviceId: 'd1', + targetTabId: 't1', + customerName: 'Jan', + amountInCents: 2500, + expectedVersion: 3, + paymentMethod: 'cash', + requestedBy: 'Staff', + }), + }); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' }); + expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands'); + }); + + it('rejects a tab no device owns', async () => { + const { service } = createService({ ownership: null }); + + await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects a tab that changed since it was loaded', async () => { + const { service, tx } = createService({ snapshot: { customerName: 'Jan', totalInCents: 3000, version: 4 } }); + + await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'version_mismatch' } }); + expect(tx.remoteCommand.create).not.toHaveBeenCalled(); + }); + + it('rejects a tab whose device was revoked', async () => { + const { service } = createService({ device: { id: 'd1', revokedAt: new Date(), lastSeenAt: new Date() } }); + + await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'device_revoked' } }); + }); + + it('rejects a second command while one is pending', async () => { + const { service, tx } = createService({ pending: { id: 'c0' } }); + + await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'command_already_pending' } }); + expect(tx.remoteCommand.create).not.toHaveBeenCalled(); + }); + + it('reports a lost race between two simultaneous requests as already pending', async () => { + const { service, prisma } = createService(); + prisma.$transaction.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('serialization failure', { code: 'P2034', clientVersion: 'test' }), + ); + + const error = await service.create('v1', dto, user).catch((e) => e); + + expect(error).toBeInstanceOf(ConflictException); + expect(error.response.code).toBe('command_already_pending'); + }); + + it('warns when the terminal has not been seen recently', async () => { + const { service } = createService({ device: { id: 'd1', revokedAt: null, lastSeenAt: new Date(Date.now() - 60 * 60 * 1000) } }); + + const result = await service.create('v1', dto, user); + + expect(result).toHaveProperty('warning'); + }); +}); diff --git a/apps/api/src/commands/commands.service.ts b/apps/api/src/commands/commands.service.ts index bcbd778..57d65cf 100644 --- a/apps/api/src/commands/commands.service.ts +++ b/apps/api/src/commands/commands.service.ts @@ -5,6 +5,7 @@ import { ConflictException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { RealtimeService } from '../realtime/realtime.service'; @@ -35,7 +36,7 @@ export class CommandsService { }); } - // Use transaction for consistency + // Serializable so two simultaneous requests for the same tab can't both pass the pending check const result = await this.prisma.$transaction(async (tx) => { // Resolve tab ownership const ownership = await tx.tabOwnership.findUnique({ @@ -93,6 +94,8 @@ export class CommandsService { venueId, deviceId: device.id, targetTabId: dto.tabId, + customerName: snapshot?.customerName ?? null, + amountInCents: snapshot?.totalInCents ?? null, type: dto.type || 'close_tab', expectedVersion: dto.expectedVersion, paymentMethod: dto.paymentMethod, @@ -130,6 +133,14 @@ export class CommandsService { status: command.status, ...warning, }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }).catch((error) => { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034') { + throw new ConflictException({ + code: 'command_already_pending', + message: 'A command for this tab is already pending', + }); + } + throw error; }); this.realtime.notify(venueId, 'commands'); @@ -152,6 +163,8 @@ export class CommandsService { select: { id: true, targetTabId: true, + customerName: true, + amountInCents: true, type: true, status: true, paymentMethod: true, @@ -173,6 +186,8 @@ export class CommandsService { commands: commands.map((c) => ({ id: c.id, tabId: c.targetTabId, + customerName: c.customerName, + amountInCents: c.amountInCents, type: c.type, status: c.status, paymentMethod: c.paymentMethod, diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index fc77847..ceeeb67 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -1,12 +1,12 @@ import { Prisma } from '@prisma/client'; import { DevicesService } from './devices.service'; -type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number }; +type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown }; 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() }, + tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() }, + openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() }, plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) }, device: { update: jest.fn() }, }; @@ -27,13 +27,13 @@ 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 () => { + it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => { const { service, tx } = createService(); - await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', 'Piet')] }); + await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', ' piet Peeters')] }); expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({ - where: { venueId: 'v1', deliveredAt: null, customerName: { in: ['Jan', 'Piet'] } }, + where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } }, data: { deliveredAt: expect.any(Date) }, }); }); @@ -125,3 +125,32 @@ describe('DevicesService.sync tab lines', () => { expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull); }); }); + +describe('DevicesService.sync writes', () => { + const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }]; + const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items }; + // JSONB doesn't keep key order + const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items: [{ quantity: 10, productName: 'Cola', unitPriceInCents: 250 }] }; + + it('only bumps the report time of unchanged tabs', async () => { + const { service, tx } = createService({ previous: [janSnapshot] }); + + await service.sync(device, { deviceId: 'd1', openTabs: [jan] }); + + expect(tx.openTabSnapshot.upsert).not.toHaveBeenCalled(); + expect(tx.tabOwnership.upsert).not.toHaveBeenCalled(); + expect(tx.openTabSnapshot.updateMany).toHaveBeenCalledWith({ + where: { deviceId: 'd1', tabId: { in: ['t1'] } }, + data: { reportedAt: expect.any(Date) }, + }); + }); + + it('writes a tab whose lines changed', async () => { + const { service, tx } = createService({ previous: [janSnapshot] }); + + await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, items: [{ ...items[0], quantity: 11 }] }] }); + + expect(tx.openTabSnapshot.upsert).toHaveBeenCalledTimes(1); + expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 0289232..ead85de 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -10,7 +10,8 @@ import { AuditService } from '../audit/audit.service'; import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service'; import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared'; import { Prisma } from '@prisma/client'; -import { OpenTabDto, SyncDto } from './dto/sync.dto'; +import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto'; +import { customerNameKey } from '../planned-tabs/customer-name'; import { AckCommandDto } from './dto/ack-command.dto'; @Injectable() @@ -36,22 +37,28 @@ export class DevicesService { // 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 }, + select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true, items: 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 } }, }); + const takenOverIds = new Set(takenOver.map((o) => o.tabId)); + + // Only new or changed tabs are written; unchanged ones just get their report time bumped + const changedTabs = openTabs.filter((t) => { + const p = previousById.get(t.id); + return !p || takenOverIds.has(t.id) || p.customerName !== t.customerName || p.version !== t.version + || p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount + || !sameItems(p.items as StoredItem[] | null, t.items); + }); + const changedIds = new Set(changedTabs.map((t) => t.id)); + const unchangedIds = openTabIds.filter((id) => !changedIds.has(id)); + const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0; + let plannedDelivered = 0; + for (const ownership of takenOver) { await this.auditService.log( device.venueId, @@ -71,7 +78,18 @@ export class DevicesService { where: { deviceId: device.id, tabId: { notIn: openTabIds } }, }); - for (const tab of openTabs) { + if (unchangedIds.length > 0) { + await tx.openTabSnapshot.updateMany({ + where: { deviceId: device.id, tabId: { in: unchangedIds } }, + data: { reportedAt: now }, + }); + await tx.tabOwnership.updateMany({ + where: { venueId: device.venueId, deviceId: device.id, tabId: { in: unchangedIds } }, + data: { lastReportedAt: now }, + }); + } + + for (const tab of changedTabs) { await tx.openTabSnapshot.upsert({ where: { deviceId_tabId: { deviceId: device.id, tabId: tab.id }, @@ -111,14 +129,12 @@ 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) }, + customerNameKey: { in: openTabs.map((t) => customerNameKey(t.customerName)) }, }, data: { deliveredAt: now }, })); @@ -279,8 +295,21 @@ export class DevicesService { } } +type StoredItem = { productName: string; quantity: number; unitPriceInCents: number }; + +function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean { + if (!stored || !reported) return !stored && !reported; + return stored.length === reported.length && stored.every((s, i) => + s.productName === reported[i].productName + && s.quantity === reported[i].quantity + && s.unitPriceInCents === reported[i].unitPriceInCents); +} + +function storedItems(items: OpenTabItemDto[]): StoredItem[] { + return items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents })); +} + /** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */ function snapshotItems(tab: OpenTabDto) { - if (!tab.items) return Prisma.JsonNull; - return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents })); + return tab.items ? storedItems(tab.items) : Prisma.JsonNull; } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7726550..3570778 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -39,20 +39,23 @@ async function bootstrap() { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new LoggingInterceptor()); - const config = new DocumentBuilder() - .setTitle('KoolTap Remote Tab Control API') - .setDescription('API for managing remote tab control in venues') - .setVersion('1.0') - .addBearerAuth() - .build(); + const swagger = process.env.NODE_ENV !== 'production'; + if (swagger) { + const config = new DocumentBuilder() + .setTitle('KoolTap Remote Tab Control API') + .setDescription('API for managing remote tab control in venues') + .setVersion('1.0') + .addBearerAuth() + .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, document); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + } const port = process.env.PORT || 3001; await app.listen(port); console.log(`API running on http://localhost:${port}`); - console.log(`Swagger docs at http://localhost:${port}/api/docs`); + if (swagger) console.log(`Swagger docs at http://localhost:${port}/api/docs`); } bootstrap(); \ No newline at end of file diff --git a/apps/api/src/planned-tabs/customer-name.ts b/apps/api/src/planned-tabs/customer-name.ts new file mode 100644 index 0000000..d6eb519 --- /dev/null +++ b/apps/api/src/planned-tabs/customer-name.ts @@ -0,0 +1,8 @@ +export function normalizeCustomerName(name: string): string { + return name.trim().replace(/\s+/g, ' '); +} + +/** The key customers are matched on: names differing only in case or spacing are the same customer. */ +export function customerNameKey(name: string): string { + return normalizeCustomerName(name).toLowerCase(); +} diff --git a/apps/api/src/planned-tabs/planned-tabs.service.ts b/apps/api/src/planned-tabs/planned-tabs.service.ts index 4c3bdca..84f6147 100644 --- a/apps/api/src/planned-tabs/planned-tabs.service.ts +++ b/apps/api/src/planned-tabs/planned-tabs.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException } import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; import { v4 as uuidv4 } from 'uuid'; +import { customerNameKey, normalizeCustomerName } from './customer-name'; @Injectable() export class PlannedTabsService { @@ -20,12 +21,12 @@ export class PlannedTabsService { } async create(venueId: string, customerName: string, email?: string) { - const name = customerName.trim(); + const name = normalizeCustomerName(customerName); if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' }); + const key = customerNameKey(name); - // Check for duplicate const existing = await this.prisma.plannedTab.findUnique({ - where: { venueId_customerName: { venueId, customerName: name } }, + where: { venueId_customerNameKey: { venueId, customerNameKey: key } }, }); if (existing) { throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' }); @@ -42,6 +43,7 @@ export class PlannedTabsService { id: uuidv4(), venueId, customerName: name, + customerNameKey: key, email: email?.trim() || null, position: (last?.position ?? -1) + 1, }, @@ -53,14 +55,15 @@ export class PlannedTabsService { } async update(venueId: string, id: string, customerName: string, email?: string) { - const name = customerName.trim(); + const name = normalizeCustomerName(customerName); if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' }); + const key = customerNameKey(name); 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 } }, + where: { venueId_customerNameKey: { venueId, customerNameKey: key } }, }); if (duplicate && duplicate.id !== id) { throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' }); @@ -68,7 +71,7 @@ export class PlannedTabsService { const result = await this.prisma.plannedTab.update({ where: { id }, - data: { customerName: name, email: email?.trim() || null }, + data: { customerName: name, customerNameKey: key, email: email?.trim() || null }, select: { id: true, customerName: true, email: true, position: true, createdAt: true }, }); @@ -85,16 +88,16 @@ export class PlannedTabsService { async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') { const incoming = new Map(); for (const entry of entries) { - const customerName = entry.customerName.trim().replace(/\s+/g, ' '); + const customerName = normalizeCustomerName(entry.customerName); if (!customerName) continue; - const key = customerName.toLowerCase(); + const key = customerNameKey(customerName); const previous = incoming.get(key); incoming.set(key, { customerName, email: entry.email || previous?.email }); } 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])); + const existingByKey = new Map(existing.map((t) => [customerNameKey(t.customerName), t])); let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1; let created = 0; @@ -106,7 +109,7 @@ export class PlannedTabsService { const current = existingByKey.get(key); if (!current) { await tx.plannedTab.create({ - data: { id: uuidv4(), venueId, customerName: entry.customerName, email: entry.email ?? null, position: nextPosition++ }, + data: { id: uuidv4(), venueId, customerName: entry.customerName, customerNameKey: key, email: entry.email ?? null, position: nextPosition++ }, }); created++; } else if (entry.email && entry.email !== current.email) { @@ -118,7 +121,7 @@ export class PlannedTabsService { } if (mode === 'replace') { - const toRemove = existing.filter((t) => !incoming.has(t.customerName.toLowerCase())).map((t) => t.id); + const toRemove = existing.filter((t) => !incoming.has(customerNameKey(t.customerName))).map((t) => t.id); if (toRemove.length > 0) { removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count; } diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts index 5906831..9e24c81 100644 --- a/apps/api/src/reminders/reminders.controller.ts +++ b/apps/api/src/reminders/reminders.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common'; +import { Controller, Get, Post, Param, UseGuards } 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'; @@ -21,11 +21,14 @@ export class RemindersController { } @Post('send') - @ApiOperation({ summary: 'Send payment reminders via Resend' }) + @UseGuards(RolesGuard) + @Roles('admin') + @ApiOperation({ summary: 'Send payment reminders via Resend (admin only)' }) @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'); + @ApiResponse({ status: 409, description: 'Reminders are already being sent' }) + async send(@Param('venueId') venueId: string) { + return this.service.sendReminders(venueId); } @Get() diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts index 5251717..af514f4 100644 --- a/apps/api/src/reminders/reminders.service.spec.ts +++ b/apps/api/src/reminders/reminders.service.spec.ts @@ -1,7 +1,8 @@ +import { ConflictException } from '@nestjs/common'; import { EmailTemplatesService } from '../email/email-templates.service'; import { RemindersService } from './reminders.service'; -function createService(items: unknown) { +function createService(items: unknown, { plannedName = 'Bram' } = {}) { const prisma = { tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) }, openTabSnapshot: { @@ -9,22 +10,23 @@ function createService(items: unknown) { { tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items }, ]), }, - plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Bram', email: 'bram@example.com' }]) }, - tabReminder: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn() }, + plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) }, + tabReminder: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn() }, venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) }, }; - return new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService()); + const service = new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService()); + return { service, prisma }; } describe('RemindersService.sendReminders', () => { const originalFetch = global.fetch; - let sentBodies: any[]; + let requests: { url: string; headers: Record; body: any }[]; beforeEach(() => { process.env.RESEND_API_KEY = 'test-key'; - sentBodies = []; - global.fetch = jest.fn(async (_url: any, init: any) => { - sentBodies.push(JSON.parse(init.body)); + requests = []; + global.fetch = jest.fn(async (url: any, init: any) => { + requests.push({ url, headers: init.headers, body: JSON.parse(init.body) }); return { ok: true, text: async () => '' } as Response; }) as any; }); @@ -35,15 +37,15 @@ describe('RemindersService.sendReminders', () => { }); it('includes the tab summary with Belgian amounts', async () => { - const service = createService([ + const { service } = createService([ { productName: 'Cola', quantity: 10, unitPriceInCents: 250 }, { productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, ]); - const result = await service.sendReminders('v1', 'admin@example.com'); + const result = await service.sendReminders('v1'); expect(result.sent).toBe(1); - const [email] = sentBodies; + const [email] = requests[0].body; expect(email.to).toBe('bram@example.com'); expect(email.text).toMatch(/10x Cola\s+€\s25,00/); expect(email.text).toMatch(/2x Chips\s+€\s4,00/); @@ -52,12 +54,60 @@ describe('RemindersService.sendReminders', () => { }); it('leaves the summary out when the terminal did not report lines', async () => { - const service = createService(null); + const { service } = createService(null); - await service.sendReminders('v1', 'admin@example.com'); + await service.sendReminders('v1'); - const [email] = sentBodies; + const [email] = requests[0].body; expect(email.text).not.toContain('10x'); expect(email.text).toMatch(/€\s29,00/); }); + + it('sends one batch request with an idempotency key and records the reminders', async () => { + const { service, prisma } = createService(null); + + await service.sendReminders('v1'); + + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe('https://api.resend.com/emails/batch'); + expect(requests[0].headers['Idempotency-Key']).toMatch(/^reminders-[0-9a-f]{64}$/); + expect(prisma.tabReminder.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tabId: 't1', email: 'bram@example.com', amountInCents: 2900 })], + }); + }); + + it('matches planned customers regardless of case and spacing', async () => { + const { service } = createService(null, { plannedName: ' bRAM ' }); + + const { candidates } = await service.listCandidates('v1'); + + expect(candidates.map((c) => c.email)).toEqual(['bram@example.com']); + }); + + it('refuses a second send for the venue while one is in progress', async () => { + const { service } = createService(null); + + const first = service.sendReminders('v1'); + await expect(service.sendReminders('v1')).rejects.toBeInstanceOf(ConflictException); + await first; + await expect(service.sendReminders('v1')).resolves.toMatchObject({ sent: 1 }); + }); + + it('does not record reminders when Resend rejects the batch', async () => { + global.fetch = jest.fn(async () => ({ ok: false, text: async () => 'rate limited' }) as Response) as any; + const { service, prisma } = createService(null); + + const result = await service.sendReminders('v1'); + + expect(result).toMatchObject({ sent: 0, total: 1, errors: [{ customerName: 'Bram', error: 'rate limited' }] }); + expect(prisma.tabReminder.createMany).not.toHaveBeenCalled(); + }); + + it('lets database errors surface instead of re-sending to everyone', async () => { + const { service, prisma } = createService(null); + prisma.tabReminder.findMany.mockRejectedValue(new Error('db down')); + + await expect(service.sendReminders('v1')).rejects.toThrow('db down'); + expect(requests).toHaveLength(0); + }); }); diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index 766b2e8..7f2a0c0 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -1,16 +1,31 @@ -import { Injectable, BadRequestException, NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import { Injectable, BadRequestException, ConflictException, NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import * as crypto from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service'; +import { customerNameKey } from '../planned-tabs/customer-name'; // 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; +// Resend accepts at most 100 emails per batch request +const BATCH_SIZE = 100; + +type Candidate = { + tabId: string; + customerName: string; + email: string; + amountInCents: number; + itemCount: number; + items: TabLine[] | null; +}; + @Injectable() export class RemindersService { private resendApiKey: string; private fromEmail: string; + private sendingVenues = new Set(); constructor( private prisma: PrismaService, @@ -21,7 +36,7 @@ export class RemindersService { this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap '; } - async listCandidates(venueId: string) { + async listCandidates(venueId: string): Promise<{ candidates: Candidate[] }> { const ownerships = await this.prisma.tabOwnership.findMany({ where: { venueId }, }); @@ -32,37 +47,24 @@ export class RemindersService { OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })), }, }); - const latestByTab = new Map(snapshots.map((s) => [s.tabId, s])); - const emailMap = new Map(); - 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 - } + const plannedTabs = await this.prisma.plannedTab.findMany({ + where: { venueId, email: { not: null } }, + select: { customerName: true, email: true }, + }); + const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!])); - let sentTabIds = new Set(); - 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 alreadySent = await this.prisma.tabReminder.findMany({ + where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } }, + select: { tabId: true }, + }); + const sentTabIds = new Set(alreadySent.map((r) => r.tabId)); - const candidates = []; - for (const snap of latestByTab.values()) { + const candidates: Candidate[] = []; + for (const snap of snapshots) { if (snap.totalInCents <= 0) continue; if (sentTabIds.has(snap.tabId)) continue; - const email = emailMap.get(snap.customerName.toLowerCase()); + const email = emailByKey.get(customerNameKey(snap.customerName)); if (!email) continue; candidates.push({ tabId: snap.tabId, @@ -78,13 +80,7 @@ export class RemindersService { 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' }; - } - + async sendReminders(venueId: string) { if (!this.resendApiKey) { throw new BadRequestException({ code: 'resend_not_configured', @@ -92,64 +88,71 @@ export class RemindersService { }); } + // A double click or a second admin must not email everyone twice + if (this.sendingVenues.has(venueId)) { + throw new ConflictException({ code: 'reminders_in_progress', message: 'Reminders are already being sent' }); + } + this.sendingVenues.add(venueId); + + try { + return await this.sendToCandidates(venueId); + } finally { + this.sendingVenues.delete(venueId); + } + } + + private async sendToCandidates(venueId: string) { + const { candidates } = await this.listCandidates(venueId); + + if (candidates.length === 0) { + return { sent: 0, total: 0 }; + } + const venueName = await this.venueName(venueId); let sent = 0; const errors: { customerName: string; error: string }[] = []; + const rendered: { candidate: Candidate; email: RenderedEmail }[] = []; for (const candidate of candidates) { try { - const email = await this.emailTemplates.render('payment-reminder', { - customerName: candidate.customerName, - venueName, - amount: formatEuro(candidate.amountInCents), - itemCount: candidate.itemCount, - hasItems: (candidate.items?.length ?? 0) > 0, - items: (candidate.items ?? []).map((item) => ({ - quantity: item.quantity, - productName: item.productName, - unitPrice: formatEuro(item.unitPriceInCents), - lineTotal: formatEuro(item.quantity * item.unitPriceInCents), - })), - }); - - const error = await this.sendEmail(candidate.email, email); - if (error) { - errors.push({ customerName: candidate.customerName, error }); - continue; - } - - await this.prisma.tabReminder.create({ - data: { - venueId, - tabId: candidate.tabId, - customerName: candidate.customerName, - email: candidate.email, - amountInCents: candidate.amountInCents, - }, - }); - - sent++; + rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) }); } catch (err: any) { errors.push({ customerName: candidate.customerName, error: err.message }); } } + for (let i = 0; i < rendered.length; i += BATCH_SIZE) { + const batch = rendered.slice(i, i + BATCH_SIZE); + const error = await this.sendBatch(batch.map(({ candidate, email }) => ({ to: candidate.email, email }))); + if (error) { + for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error }); + continue; + } + + await this.prisma.tabReminder.createMany({ + data: batch.map(({ candidate }) => ({ + venueId, + tabId: candidate.tabId, + customerName: candidate.customerName, + email: candidate.email, + amountInCents: candidate.amountInCents, + })), + }); + sent += batch.length; + } + if (sent > 0) this.realtime.notify(venueId, 'reminders'); 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: [] }; - } + const reminders = await this.prisma.tabReminder.findMany({ + where: { venueId }, + orderBy: { sentAt: 'desc' }, + take: 100, + }); + return { reminders }; } async sendTestEmail(venueId: string, plannedTabId: string) { @@ -186,7 +189,7 @@ export class RemindersService { throw error; } - const error = await this.sendEmail(tab.email, email); + const error = await this.resend('/emails', { from: this.fromEmail, to: tab.email, ...email }); if (error) { throw new BadRequestException({ code: 'email_failed', @@ -197,15 +200,39 @@ export class RemindersService { return { ok: true, email: tab.email, customerName: tab.customerName }; } - /** Sends through Resend; returns Resend's error text, or null when sent. */ - private async sendEmail(to: string, email: RenderedEmail): Promise { - const res = await fetch('https://api.resend.com/emails', { + private renderReminder(candidate: Candidate, venueName: string): Promise { + return this.emailTemplates.render('payment-reminder', { + customerName: candidate.customerName, + venueName, + amount: formatEuro(candidate.amountInCents), + itemCount: candidate.itemCount, + hasItems: (candidate.items?.length ?? 0) > 0, + items: (candidate.items ?? []).map((item) => ({ + quantity: item.quantity, + productName: item.productName, + unitPrice: formatEuro(item.unitPriceInCents), + lineTotal: formatEuro(item.quantity * item.unitPriceInCents), + })), + }); + } + + /** One Resend batch request; returns Resend's error text, or null when sent. */ + private sendBatch(messages: { to: string; email: RenderedEmail }[]): Promise { + const body = messages.map(({ to, email }) => ({ from: this.fromEmail, to, ...email })); + // Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice + const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'); + return this.resend('/emails/batch', body, idempotencyKey); + } + + private async resend(path: string, body: unknown, idempotencyKey?: string): Promise { + const res = await fetch(`https://api.resend.com${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.resendApiKey}`, + ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), }, - body: JSON.stringify({ from: this.fromEmail, to, ...email }), + body: JSON.stringify(body), }); return res.ok ? null : await res.text(); @@ -224,4 +251,4 @@ const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' function formatEuro(cents: number): string { return euro.format(cents / 100); -} \ No newline at end of file +} diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts index 746b559..9587eee 100644 --- a/apps/api/src/tabs/tabs.service.ts +++ b/apps/api/src/tabs/tabs.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { customerNameKey } from '../planned-tabs/customer-name'; @Injectable() export class TabsService { @@ -15,7 +16,7 @@ export class TabsService { }, }); - const snapshots = await this.prisma.openTabSnapshot.findMany({ + const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({ where: { OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })), }, @@ -25,15 +26,16 @@ export class TabsService { // 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 }, + select: { id: true, customerNameKey: true, email: true }, }); - const plannedByName = new Map(planned.map((p) => [p.customerName, p])); + const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p])); const tabs = []; for (const ownership of ownerships) { const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`); if (snapshot) { + const plannedTab = plannedByKey.get(customerNameKey(snapshot.customerName)); tabs.push({ tabId: snapshot.tabId, customerName: snapshot.customerName, @@ -47,8 +49,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, + plannedTabId: plannedTab?.id ?? null, + email: plannedTab?.email ?? null, }); } } diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts index 19e9b68..f78bc02 100644 --- a/apps/api/test/app.e2e-spec.ts +++ b/apps/api/test/app.e2e-spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; -import * as request from 'supertest'; +import request = require('supertest'); import { AppModule } from '../src/app.module'; import { PrismaService } from '../src/prisma/prisma.service'; import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter'; @@ -42,7 +42,7 @@ describe('KoolTap API (e2e)', () => { it('POST /api/v1/auth/login with invalid credentials should return 401', () => { return request(app.getHttpServer()) .post('/api/v1/auth/login') - .send({ email: 'nonexistent@test.com', password: 'wrong' }) + .send({ email: 'nonexistent@test.com', password: 'wrong-password' }) .expect(401); }); @@ -50,8 +50,9 @@ describe('KoolTap API (e2e)', () => { // First create a user via seed or directly const bcrypt = require('bcrypt'); const hash = await bcrypt.hash('Test123!', 10); - const uuid = require('uuid').v4(); - const venueId = uuid; + const { v4: uuid } = require('uuid'); + const venueId = uuid(); + const email = `test-${venueId}@test.com`; await prisma.venue.create({ data: { id: venueId, name: 'Test Venue' }, @@ -59,9 +60,9 @@ describe('KoolTap API (e2e)', () => { await prisma.clientUser.create({ data: { - id: uuid, + id: uuid(), venueId, - email: 'test@test.com', + email, passwordHash: hash, name: 'Test User', role: 'admin', @@ -70,12 +71,12 @@ describe('KoolTap API (e2e)', () => { return request(app.getHttpServer()) .post('/api/v1/auth/login') - .send({ email: 'test@test.com', password: 'Test123!' }) + .send({ email, password: 'Test123!' }) .expect(200) .expect((res) => { expect(res.body.accessToken).toBeDefined(); expect(res.body.refreshToken).toBeDefined(); - expect(res.body.user.email).toBe('test@test.com'); + expect(res.body.user.email).toBe(email); }); }); }); @@ -83,21 +84,20 @@ describe('KoolTap API (e2e)', () => { describe('Venue isolation', () => { it('should not allow cross-venue access', async () => { const bcrypt = require('bcrypt'); - const uuid = require('uuid').v4(); - const venueAId = uuid; - const venueBId = uuid; + const { v4: uuid } = require('uuid'); + const venueAId = uuid(); + const venueBId = uuid(); + const email = `venuea-${venueAId}@test.com`; await prisma.venue.create({ data: { id: venueAId, name: 'Venue A' } }); await prisma.venue.create({ data: { id: venueBId, name: 'Venue B' } }); const hash = await bcrypt.hash('Pass123!', 10); - const userId = uuid; - await prisma.clientUser.create({ data: { - id: userId, + id: uuid(), venueId: venueAId, - email: 'venuea@test.com', + email, passwordHash: hash, name: 'Venue A User', role: 'admin', @@ -107,7 +107,7 @@ describe('KoolTap API (e2e)', () => { // Login as Venue A user const loginRes = await request(app.getHttpServer()) .post('/api/v1/auth/login') - .send({ email: 'venuea@test.com', password: 'Pass123!' }) + .send({ email, password: 'Pass123!' }) .expect(200); const token = loginRes.body.accessToken; @@ -116,11 +116,9 @@ describe('KoolTap API (e2e)', () => { await request(app.getHttpServer()) .get(`/api/v1/venues/${venueBId}/tabs`) .set('Authorization', `Bearer ${token}`) - .expect(200) // Should return empty - venue scoping is by user venueId, not by URL param + .expect(403) .expect((res) => { - // The endpoint uses the user's venueId from JWT - // But for cross-venue tests we need the endpoint to verify - expect(Array.isArray(res.body.tabs)).toBe(true); + expect(res.body.error.code).toBe('venue_forbidden'); }); }); }); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json index ac432d9..fd8633b 100644 --- a/apps/api/test/jest-e2e.json +++ b/apps/api/test/jest-e2e.json @@ -2,6 +2,9 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", + "moduleNameMapper": { + "^@kooltap/shared$": "/../../../packages/shared/src" + }, "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/apps/dashboard/src/app/dashboard/commands/page.tsx b/apps/dashboard/src/app/dashboard/commands/page.tsx index bc71d8f..75c9b4b 100644 --- a/apps/dashboard/src/app/dashboard/commands/page.tsx +++ b/apps/dashboard/src/app/dashboard/commands/page.tsx @@ -1,9 +1,8 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { api } from '@/lib/api/client'; +import { useState } from 'react'; +import { commandsApi } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; @@ -16,6 +15,8 @@ import { ErrorState } from '@/components/error-state'; import { Filter } from 'lucide-react'; import { useRealtime } from '@/lib/realtime'; import { useLang } from '@/lib/i18n'; +import { useSession } from '@/lib/session'; +import { formatCents } from '@/lib/utils'; const statusFilters = [ { value: '', label: 'commands.all' }, @@ -29,40 +30,24 @@ const statusFilters = [ export default function CommandsPage() { // Live updates make polling unnecessary; it only runs while the socket is down const { connected: live } = useRealtime(); - const { t, tn, relativeTime } = useLang(); - const router = useRouter(); - const [venueId, setVenueId] = useState(null); + const { t, tn, te, relativeTime } = useLang(); + const { venueId } = useSession(); const [statusFilter, setStatusFilter] = useState(''); const [page, setPage] = useState(0); const limit = 20; - useEffect(() => { - const cookie = document.cookie - .split('; ') - .find((row) => row.startsWith('access_token=')); - if (!cookie) { - router.push('/login'); - return; - } - const payload = JSON.parse(atob(cookie.split('.')[1])); - setVenueId(payload.venueId); - }, [router]); - const { data, isLoading, error } = useQuery({ queryKey: ['commands', venueId, statusFilter, page], - queryFn: () => - api(`/venues/${venueId}/commands?limit=${limit}&offset=${page * limit}${statusFilter ? `&status=${statusFilter}` : ''}`), - enabled: !!venueId, + queryFn: () => commandsApi.list(venueId, { limit, offset: page * limit, status: statusFilter || undefined }), refetchInterval: live ? false : 10000, }); - const commands = data?.commands || []; - const total = data?.total || 0; + const commands = data?.commands ?? []; + const total = data?.total ?? 0; const totalPages = Math.ceil(total / limit); - if (!venueId) return ; if (isLoading) return ; - if (error) return ; + if (error) return ; return (
@@ -93,7 +78,8 @@ export default function CommandsPage() { - {t('commands.tab')} + {t('commands.customer')} + {t('commands.amount')} {t('commands.requested')} {t('commands.payment')} {t('commands.created')} @@ -103,9 +89,10 @@ export default function CommandsPage() { - {commands.map((cmd: any) => ( + {commands.map((cmd) => ( - {cmd.tabId.substring(0, 8)} + {cmd.customerName ?? {cmd.tabId.substring(0, 8)}} + {cmd.amountInCents != null ? formatCents(cmd.amountInCents) : '-'} {cmd.requestedBy} {relativeTime(cmd.createdAt)} diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index 5403812..49720e8 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -18,9 +18,11 @@ 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 { authApi, venuesApi } from '@/lib/api/client'; import { toast } from '@/components/ui/toast'; import { RealtimeProvider, useRealtime } from '@/lib/realtime'; +import { readSession, Session, SessionProvider } from '@/lib/session'; +import { LoadingState } from '@/components/loading-state'; const navItems = [ { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false }, @@ -39,39 +41,22 @@ export default function DashboardLayout({ const router = useRouter(); const pathname = usePathname(); const [sidebarOpen, setSidebarOpen] = useState(false); - const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null); + const [user, setUser] = useState(null); const [venue, setVenue] = useState<{ name: string } | null>(null); const { t } = useLang(); useEffect(() => { - // Check auth - const cookie = document.cookie - .split('; ') - .find((row) => row.startsWith('access_token=')); - if (!cookie) { - router.push('/login'); + const session = readSession(); + if (!session) { + router.replace('/login'); return; } - - // Decode JWT to get basic user info - try { - const payload = JSON.parse(atob(cookie.split('.')[1])); - setUser({ - name: payload.email, - email: payload.email, - role: payload.role, - venueId: payload.venueId, - }); - - // Fetch venue name - api(`/venues/${payload.venueId}`).then((v: any) => { - setVenue(v); - }).catch(() => {}); - } catch { - router.push('/login'); - } + setUser(session); + venuesApi.get(session.venueId).then(setVenue).catch(() => {}); }, [router]); + if (!user) return ; + function handleLogout() { authApi.logout(); toast({ title: t('common.logged_out'), variant: 'default' }); @@ -79,6 +64,7 @@ export default function DashboardLayout({ } return ( +
{/* Mobile sidebar toggle */} @@ -118,7 +104,7 @@ export default function DashboardLayout({ const Icon = item.icon; const active = pathname === item.href; - if (item.adminOnly && user?.role !== 'admin') { + if (item.adminOnly && user.role !== 'admin') { return null; } @@ -142,13 +128,12 @@ export default function DashboardLayout({
+ - {user && ( -
-

{user.name}

-

{user.role}

-
- )} +
+

{user.email}

+

{t(`users.role.${user.role}`)}

+
+ ); } diff --git a/apps/dashboard/src/app/dashboard/page.tsx b/apps/dashboard/src/app/dashboard/page.tsx index 6ec6637..3631033 100644 --- a/apps/dashboard/src/app/dashboard/page.tsx +++ b/apps/dashboard/src/app/dashboard/page.tsx @@ -1,80 +1,67 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { api } from '@/lib/api/client'; +import { commandsApi, devicesApi, tabsApi } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { LoadingState } from '@/components/loading-state'; import { CommandStatusBadge } from '@/components/command-status-badge'; -import { ClipboardList, Terminal, Monitor } from 'lucide-react'; +import { DeviceStatusBadge } from '@/components/device-status-badge'; +import { ClipboardList, Terminal, Monitor, Euro } from 'lucide-react'; import { useRealtime } from '@/lib/realtime'; import { useLang } from '@/lib/i18n'; +import { useSession } from '@/lib/session'; +import { formatCents } from '@/lib/utils'; export default function DashboardPage() { // Live updates make polling unnecessary; it only runs while the socket is down const { connected: live } = useRealtime(); const { t, relativeTime } = useLang(); - const router = useRouter(); - const [venueId, setVenueId] = useState(null); - - useEffect(() => { - const cookie = document.cookie - .split('; ') - .find((row) => row.startsWith('access_token=')); - if (!cookie) { - router.push('/login'); - return; - } - const payload = JSON.parse(atob(cookie.split('.')[1])); - setVenueId(payload.venueId); - }, [router]); + const { venueId } = useSession(); const tabsQuery = useQuery({ queryKey: ['tabs', venueId], - queryFn: () => api(`/venues/${venueId}/tabs`), - enabled: !!venueId, + queryFn: () => tabsApi.list(venueId), refetchInterval: live ? false : 10000, }); const deviceQuery = useQuery({ queryKey: ['device', venueId], - queryFn: () => api(`/venues/${venueId}/devices`), - enabled: !!venueId, + queryFn: () => devicesApi.get(venueId), // A terminal going offline sends no event, so its status is always polled refetchInterval: 30000, }); - const commandsQuery = useQuery({ + const recentQuery = useQuery({ queryKey: ['commands', venueId, 'recent'], - queryFn: () => api(`/venues/${venueId}/commands?limit=5`), - enabled: !!venueId, + queryFn: () => commandsApi.list(venueId, { limit: 5 }), refetchInterval: live ? false : 15000, }); - if (!venueId) return ; + const pendingQuery = useQuery({ + queryKey: ['commands', venueId, 'pending-count'], + queryFn: () => commandsApi.list(venueId, { status: 'pending', limit: 1 }), + refetchInterval: live ? false : 15000, + }); - const tabs = tabsQuery.data?.tabs || []; + const tabs = tabsQuery.data?.tabs ?? []; const device = deviceQuery.data?.device; - const commands = commandsQuery.data?.commands || []; - - const pendingCommands = commands.filter((c: any) => c.status === 'pending'); - const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading; + const commands = recentQuery.data?.commands ?? []; + const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0); + const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading; return (
{isLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( +
+ {Array.from({ length: 4 }).map((_, i) => ( ))}
) : ( -
+
{t('overview.open_tabs')} @@ -82,14 +69,30 @@ export default function DashboardPage() {
{tabs.length}
+ + + {t('overview.outstanding')} + + +
{formatCents(outstanding)}
+
{t('overview.device')} -
{device?.name || t('overview.not_connected')}
- {device &&

{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}

} + {device ? ( + <> +
+ {device.name} + +
+

{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}

+ + ) : ( +
{t('overview.not_connected')}
+ )}
@@ -97,7 +100,7 @@ export default function DashboardPage() { {t('overview.pending')} -
{pendingCommands.length}
+
{pendingQuery.data?.total ?? 0}
)} @@ -109,13 +112,14 @@ export default function DashboardPage() {

{t('overview.no_commands')}

) : (
- {commands.map((cmd: any) => ( -
-
- {cmd.tabId?.substring(0, 8)} + {commands.map((cmd) => ( +
+
+ {cmd.customerName ?? cmd.tabId.substring(0, 8)} + {cmd.amountInCents != null && {formatCents(cmd.amountInCents)}} {cmd.requestedBy}
-
+
{relativeTime(cmd.createdAt)}
@@ -127,4 +131,4 @@ export default function DashboardPage() {
); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx index 22c8d76..efc5662 100644 --- a/apps/dashboard/src/app/dashboard/reminders/page.tsx +++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx @@ -1,8 +1,7 @@ 'use client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState } from 'react'; import { remindersApi } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -11,43 +10,34 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { EmptyState } from '@/components/empty-state'; -import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; import { toast } from '@/components/ui/toast'; import { formatCents } from '@/lib/utils'; import { useLang } from '@/lib/i18n'; +import { useSession } from '@/lib/session'; import { Send, History } from 'lucide-react'; export default function RemindersPage() { - const router = useRouter(); const queryClient = useQueryClient(); - const { t, relativeTime } = useLang(); - const [venueId, setVenueId] = useState(null); + const { t, te, relativeTime } = useLang(); + const { venueId, role } = useSession(); const [sendDialogOpen, setSendDialogOpen] = useState(false); const [showHistory, setShowHistory] = useState(false); - useEffect(() => { - const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token=')); - if (!cookie) { router.push('/login'); return; } - const payload = JSON.parse(atob(cookie.split('.')[1])); - setVenueId(payload.venueId); - }, [router]); - const candidatesQuery = useQuery({ queryKey: ['reminder-candidates', venueId], - queryFn: () => remindersApi.candidates(venueId!), - enabled: !!venueId, + queryFn: () => remindersApi.candidates(venueId), }); const historyQuery = useQuery({ queryKey: ['reminder-history', venueId], - queryFn: () => remindersApi.history(venueId!), - enabled: !!venueId && showHistory, + queryFn: () => remindersApi.history(venueId), + enabled: showHistory, }); const sendMutation = useMutation({ - mutationFn: () => remindersApi.send(venueId!), - onSuccess: (result: any) => { + mutationFn: () => remindersApi.send(venueId), + onSuccess: (result) => { setSendDialogOpen(false); if (result.errors && result.errors.length > 0) { const firstError = result.errors[0]; @@ -60,15 +50,14 @@ export default function RemindersPage() { queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] }); queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); }, - onError: (err: any) => { - toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' }); + onError: (err) => { + setSendDialogOpen(false); + toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); }, }); - if (!venueId) return ; - - const candidates = candidatesQuery.data?.candidates || []; - const reminders = historyQuery.data?.reminders || []; + const candidates = candidatesQuery.data?.candidates ?? []; + const reminders = historyQuery.data?.reminders ?? []; return (
@@ -106,13 +95,13 @@ export default function RemindersPage() { - {reminders.map((r: any) => ( + {reminders.map((r) => ( {r.customerName} {r.email} {formatCents(r.amountInCents)} {relativeTime(r.sentAt)} - {r.status === 'sent' ? t('reminders.status_sent') : r.status} + {r.status === 'sent' ? t('reminders.status_sent') : r.status} ))} @@ -149,7 +138,7 @@ export default function RemindersPage() { - {candidates.map((c: any) => ( + {candidates.map((c) => ( {c.customerName} {c.email} @@ -161,10 +150,14 @@ export default function RemindersPage() {
- + {role === 'admin' ? ( + + ) : ( +

{t('reminders.admin_only')}

+ )} )} @@ -184,7 +177,7 @@ export default function RemindersPage() { {t('reminders.confirm', { count: candidates.length })}

    - {candidates.map((c: any) => ( + {candidates.map((c) => (
  • {c.customerName} ({c.email}) diff --git a/apps/dashboard/src/app/dashboard/settings/page.tsx b/apps/dashboard/src/app/dashboard/settings/page.tsx index 5905c30..275f54c 100644 --- a/apps/dashboard/src/app/dashboard/settings/page.tsx +++ b/apps/dashboard/src/app/dashboard/settings/page.tsx @@ -1,44 +1,32 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; -import { api, devicesApi } from '@/lib/api/client'; +import { devicesApi, venuesApi } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; +import { DeviceStatusBadge } from '@/components/device-status-badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { toast } from '@/components/ui/toast'; import { useLang } from '@/lib/i18n'; +import { useSession } from '@/lib/session'; import { Monitor, Copy, Plus } from 'lucide-react'; export default function SettingsPage() { - const router = useRouter(); - const [venueId, setVenueId] = useState(null); - const [userRole, setUserRole] = useState(null); + const { venueId, role } = useSession(); const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null); const [generating, setGenerating] = useState(false); - const { t, relativeTime, dateTime, time } = useLang(); - - useEffect(() => { - const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token=')); - if (!cookie) { router.push('/login'); return; } - const payload = JSON.parse(atob(cookie.split('.')[1])); - setVenueId(payload.venueId); - setUserRole(payload.role); - }, [router]); + const { t, te, relativeTime, dateTime, time } = useLang(); const { data: venue } = useQuery({ queryKey: ['venue', venueId], - queryFn: () => api(`/venues/${venueId}`), - enabled: !!venueId, + queryFn: () => venuesApi.get(venueId), }); const { data: deviceData } = useQuery({ queryKey: ['device', venueId], - queryFn: () => devicesApi.get(venueId!), - enabled: !!venueId, + queryFn: () => devicesApi.get(venueId), // A terminal going offline sends no event, so its status is always polled refetchInterval: 15000, }); @@ -50,8 +38,8 @@ export default function SettingsPage() { try { const result = await devicesApi.generateCode(); setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt }); - } catch (err: any) { - toast({ title: t('common.failed'), description: err.message, variant: 'destructive' }); + } catch (err) { + toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); } finally { setGenerating(false); } @@ -91,8 +79,8 @@ export default function SettingsPage() { {device ? (
    - - {t('settings.connected')} + + {t('settings.since', { date: dateTime(device.createdAt) })}
    ) : ( @@ -100,7 +88,7 @@ export default function SettingsPage() { {t('settings.pair_desc')}

    )} - {userRole === 'admin' && ( + {role === 'admin' && ( } /> @@ -159,7 +181,7 @@ export default function TabsPage() {
    {t('planned.title')} - {userRole === 'admin' && ( + {isAdmin && ( @@ -167,7 +189,7 @@ export default function TabsPage() {
    - {userRole === 'admin' && ( + {isAdmin && (
    {t('planned.waiting_empty')}

    )} - {pendingPlanned.map((pt: any) => ( + {pendingPlanned.map((pt) => (
    {pt.customerName} {pt.email && } - {userRole === 'admin' && pt.email && ( + {isAdmin && pt.email && ( )} - {userRole === 'admin' && ( + {isAdmin && ( @@ -222,105 +244,134 @@ export default function TabsPage() { {tabs.length === 0 ? ( ) : ( -
    - - - - {t('tabs.customer')} - {t('tabs.email')} - {t('tabs.amount')} - {t('tabs.items')} - {t('tabs.version')} - {t('tabs.reported')} - {t('tabs.actions')} - - - - {tabs.map((tab: any) => { - const expanded = expandedTabs.has(tab.tabId); - const toggle = () => setExpandedTabs((current) => { - const next = new Set(current); - if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId); - return next; - }); - return ( - + <> +
    +
    + + setSearch(e.target.value)} + className="pl-8" + aria-label={t('tabs.search_placeholder')} + /> +
    + +
    + +
    +
    + - - - - - {tab.email ? ( -
    - - {tab.email} - {userRole === 'admin' && tab.plannedTabId && ( - - )} -
    - ) : ( - — - )} -
    - {formatCents(tab.totalInCents)} - {tab.itemCount} - {tab.version} - {relativeTime(tab.lastReportedAt)} - - {/* Closing settles the tab on the terminal; a tab without a balance has nothing to settle */} - - + {t('tabs.customer')} + {t('tabs.email')} + {t('tabs.amount')} + {t('tabs.items')} + {t('tabs.reported')} + {t('tabs.actions')}
    - {expanded && ( - - - {/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */} -
    - -
    +
    + + {visibleTabs.length === 0 && ( + + + {t('tabs.no_matches', { query: search.trim() })} )} - - ); - })} - -
    -
    + {visibleTabs.map((tab) => { + const expanded = expandedTabs.has(tab.tabId); + const toggle = () => setExpandedTabs((current) => { + const next = new Set(current); + if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId); + return next; + }); + return ( + + + + + + + {tab.email ? ( +
    + + {tab.email} + {isAdmin && tab.plannedTabId && ( + + )} +
    + ) : ( + — + )} +
    + {formatCents(tab.totalInCents)} + {tab.itemCount} + {relativeTime(tab.lastReportedAt)} + + {closingTabIds.has(tab.tabId) ? ( + {t('tabs.closing')} + ) : ( + // Closing settles the tab on the terminal; a tab without a balance has nothing to settle + + )} + +
    + {expanded && ( + + + {/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */} +
    + +
    +
    +
    + )} +
    + ); + })} + + +
    + )} - {venueId && ( - queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })} - /> - )} + queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })} + /> - {closeDialog.tab && ( - !open && setCloseDialog({ open: false, tab: null })}> + {closeTab && ( + !open && !createCommandMutation.isPending && setCloseTab(null)}> - {t('tabs.close_title', { name: closeDialog.tab.customerName })} + {t('tabs.close_title', { name: closeTab.customerName })} {t('tabs.close_desc')}
    -
    {t('tabs.amount')}{formatCents(closeDialog.tab.totalInCents)}
    -
    {t('tabs.items')}{closeDialog.tab.itemCount}
    -
    -
    {t('tabs.version')}{closeDialog.tab.version}
    +
    {t('tabs.amount')}{formatCents(closeTab.totalInCents)}
    +
    {t('tabs.items')}{closeTab.itemCount}
    +
    @@ -332,8 +383,8 @@ export default function TabsPage() {
    - - + +
    @@ -342,8 +393,6 @@ export default function TabsPage() { ); } -type TabLine = { productName: string; quantity: number; unitPriceInCents: number }; - /** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */ function TabLines({ items }: { items: TabLine[] | null | undefined }) { const { t } = useLang(); diff --git a/apps/dashboard/src/app/dashboard/users/page.tsx b/apps/dashboard/src/app/dashboard/users/page.tsx index fd2d7b2..8e75dad 100644 --- a/apps/dashboard/src/app/dashboard/users/page.tsx +++ b/apps/dashboard/src/app/dashboard/users/page.tsx @@ -1,8 +1,7 @@ 'use client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState } from 'react'; import { usersApi } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; @@ -17,55 +16,42 @@ import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; import { toast } from '@/components/ui/toast'; import { useLang } from '@/lib/i18n'; +import { useSession } from '@/lib/session'; import { RefreshCw, Plus } from 'lucide-react'; export default function UsersPage() { - const router = useRouter(); const queryClient = useQueryClient(); - const [venueId, setVenueId] = useState(null); - const [userRole, setUserRole] = useState(null); + const { venueId, role } = useSession(); const [addDialog, setAddDialog] = useState(false); const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' }); const [submitting, setSubmitting] = useState(false); - const { t, tn, relativeTime } = useLang(); - - useEffect(() => { - const cookie = document.cookie - .split('; ') - .find((row) => row.startsWith('access_token=')); - if (!cookie) { router.push('/login'); return; } - const payload = JSON.parse(atob(cookie.split('.')[1])); - setVenueId(payload.venueId); - setUserRole(payload.role); - }, [router]); + const { t, tn, te, relativeTime } = useLang(); const { data, isLoading, error } = useQuery({ queryKey: ['users', venueId], - queryFn: () => usersApi.list(venueId!), - enabled: !!venueId, + queryFn: () => usersApi.list(venueId), }); - const users = data?.users || []; + const users = data?.users ?? []; async function handleCreateUser() { if (!form.email || !form.password || !form.name) return; setSubmitting(true); try { - await usersApi.create(venueId!, form); + await usersApi.create(venueId, form); toast({ title: t('users.created_toast'), description: t('users.created_toast_desc', { email: form.email, role: t(`users.role.${form.role}`) }), variant: 'success' }); setAddDialog(false); setForm({ email: '', password: '', name: '', role: 'viewer' }); queryClient.invalidateQueries({ queryKey: ['users'] }); - } catch (err: any) { - toast({ title: t('users.create_failed'), description: err.message, variant: 'destructive' }); + } catch (err) { + toast({ title: t('users.create_failed'), description: te(err), variant: 'destructive' }); } finally { setSubmitting(false); } } - if (!venueId) return ; if (isLoading) return ; - if (error) return ; + if (error) return ; return (
    @@ -77,7 +63,7 @@ export default function UsersPage() { - {userRole === 'admin' && ( + {role === 'admin' && ( @@ -100,7 +86,7 @@ export default function UsersPage() { - {users.map((u: any) => ( + {users.map((u) => ( {u.name} {u.email} diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx index 1a9900f..e9e4617 100644 --- a/apps/dashboard/src/app/login/page.tsx +++ b/apps/dashboard/src/app/login/page.tsx @@ -16,7 +16,7 @@ export default function LoginPage() { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const { t } = useLang(); + const { t, te } = useLang(); async function handleSubmit(e: FormEvent) { e.preventDefault(); @@ -27,8 +27,8 @@ export default function LoginPage() { const result = await authApi.login(email, password); setTokens(result.accessToken, result.refreshToken); router.push('/dashboard'); - } catch (err: any) { - setError(err.message || t('auth.invalid')); + } catch (err) { + setError(te(err, 'auth.invalid')); } finally { setLoading(false); } diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index 95f9202..383703b 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -1,20 +1,15 @@ 'use client'; -import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { api } from '@/lib/api/client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { readSession } from '@/lib/session'; export default function Home() { + const router = useRouter(); + useEffect(() => { - const cookie = document.cookie - .split('; ') - .find((row) => row.startsWith('access_token=')); - if (cookie) { - redirect('/dashboard'); - } else { - redirect('/login'); - } - }, []); + router.replace(readSession() ? '/dashboard' : '/login'); + }, [router]); return null; -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/providers.tsx b/apps/dashboard/src/app/providers.tsx index 690da38..1a2a9ca 100644 --- a/apps/dashboard/src/app/providers.tsx +++ b/apps/dashboard/src/app/providers.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; import { LanguageProvider } from '@/lib/i18n'; +import { Toaster } from '@/components/ui/toast'; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -19,7 +20,10 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + + ); } \ No newline at end of file diff --git a/apps/dashboard/src/components/import-customers-dialog.tsx b/apps/dashboard/src/components/import-customers-dialog.tsx index e71a209..65d5c01 100644 --- a/apps/dashboard/src/components/import-customers-dialog.tsx +++ b/apps/dashboard/src/components/import-customers-dialog.tsx @@ -25,7 +25,7 @@ interface Props { } export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) { - const { t } = useLang(); + const { t, te } = useLang(); const [fileName, setFileName] = useState(null); const [sheet, setSheet] = useState(null); const [mapping, setMapping] = useState(null); @@ -70,8 +70,8 @@ export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' }); onImported(); handleOpenChange(false); - } catch (err: any) { - toast({ title: t('import.failed'), description: err.message, variant: 'destructive' }); + } catch (err) { + toast({ title: t('import.failed'), description: te(err), variant: 'destructive' }); } finally { setSubmitting(false); } diff --git a/apps/dashboard/src/components/ui/toast.tsx b/apps/dashboard/src/components/ui/toast.tsx index 3a95175..33ec8c5 100644 --- a/apps/dashboard/src/components/ui/toast.tsx +++ b/apps/dashboard/src/components/ui/toast.tsx @@ -126,30 +126,37 @@ export { ToastAction, }; -// Simple toast hook -let toastCount = 0; -const listeners: Set<(toast: any) => void> = new Set(); +type ToastMessage = { + id: number; + title?: string; + description?: string; + variant?: "default" | "destructive" | "success"; +}; +type ToastEvent = (ToastMessage & { open: true }) | { id: number; open: false }; -export function toast(props: { title?: string; description?: string; variant?: "default" | "destructive" | "success" }) { +let toastCount = 0; +const listeners: Set<(event: ToastEvent) => void> = new Set(); + +export function toast(props: Omit) { const id = ++toastCount; - const newToast = { id, ...props, open: true }; - listeners.forEach((listener) => listener(newToast)); - setTimeout(() => { - listeners.forEach((listener) => listener({ id, open: false })); - }, 5000); + listeners.forEach((listener) => listener({ id, ...props, open: true })); return id; } +function dismiss(id: number) { + listeners.forEach((listener) => listener({ id, open: false })); +} + export function useToast() { - const [toasts, setToasts] = React.useState([]); + const [toasts, setToasts] = React.useState([]); React.useEffect(() => { - const listener = (toast: any) => { + const listener = (event: ToastEvent) => { setToasts((prev) => { - if (toast.open === false) { - return prev.filter((t) => t.id !== toast.id); - } - return [...prev.filter((t) => t.id !== toast.id), toast]; + const others = prev.filter((t) => t.id !== event.id); + if (!event.open) return others; + const { open: _open, ...message } = event; + return [...others, message]; }); }; listeners.add(listener); @@ -157,4 +164,23 @@ export function useToast() { }, []); return { toasts, toast }; +} + +/** Shows the toasts; Radix closes each one after 5 seconds (paused while hovered). */ +export function Toaster() { + const { toasts } = useToast(); + return ( + + {toasts.map(({ id, title, description, variant }) => ( + !open && dismiss(id)}> +
    + {title && {title}} + {description && {description}} +
    + +
    + ))} + +
    + ); } \ No newline at end of file diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index 6580a46..450cdba 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -1,24 +1,105 @@ export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; -interface ApiError { +export interface ApiError { code: string; message: string; } -function getTokens() { - if (typeof window === 'undefined') return { accessToken: null }; - const accessToken = document.cookie +export type Role = 'admin' | 'viewer'; + +export type TabLine = { productName: string; quantity: number; unitPriceInCents: number }; + +export interface Tab { + tabId: string; + customerName: string; + version: number; + totalInCents: number; + itemCount: number; + items: TabLine[] | null; + deviceId: string; + deviceName: string; + deviceLastSeenAt: string; + deviceRevokedAt: string | null; + lastReportedAt: string; + plannedTabId: string | null; + email: string | null; +} + +export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired'; + +export interface Command { + id: string; + tabId: string; + customerName: string | null; + amountInCents: number | null; + type: string; + status: CommandStatus; + paymentMethod: string; + requestedBy: string; + expectedVersion: number; + createdAt: string; + processedAt: string | null; + result: string | null; + deviceId: string; +} + +export interface Device { + id: string; + name: string; + lastSeenAt: string; + createdAt: string; +} + +export interface User { + id: string; + email: string; + name: string; + role: Role; + createdAt: string; +} + +export interface PlannedTab { + id: string; + customerName: string; + email: string | null; + position: number; + createdAt: string; + deliveredAt: string | null; +} + +export interface ReminderCandidate { + tabId: string; + customerName: string; + email: string; + amountInCents: number; + itemCount: number; + items: TabLine[] | null; +} + +export interface Reminder { + id: string; + tabId: string; + customerName: string; + email: string; + amountInCents: number; + sentAt: string; + status: string; +} + +export function getCookie(name: string): string | null { + if (typeof document === 'undefined') return null; + return document.cookie .split('; ') - .find((row) => row.startsWith('access_token=')) - ?.split('=')[1]; - return { accessToken }; + .find((row) => row.startsWith(`${name}=`)) + ?.split('=')[1] ?? null; } function setTokens(accessToken: string, refreshToken: string) { // The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an // expired JWT triggers a 401 which is transparently refreshed below. - document.cookie = `access_token=${accessToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`; - document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`; + const attributes = `path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`; + document.cookie = `access_token=${accessToken}; ${attributes}`; + document.cookie = `refresh_token=${refreshToken}; ${attributes}`; } function clearTokens() { @@ -39,11 +120,7 @@ function refreshAccessToken(): Promise { } async function doRefreshAccessToken(): Promise { - const refreshToken = document.cookie - .split('; ') - .find((row) => row.startsWith('refresh_token=')) - ?.split('=')[1]; - + const refreshToken = getCookie('refresh_token'); if (!refreshToken) return null; try { @@ -63,11 +140,19 @@ async function doRefreshAccessToken(): Promise { } } -export async function api( +async function request(endpoint: string, options: RequestInit, headers: Record): Promise { + try { + return await fetch(`${API_URL}${endpoint}`, { ...options, headers }); + } catch (error) { + throw { code: 'network_error', message: String(error) } satisfies ApiError; + } +} + +export async function api( endpoint: string, options: RequestInit = {}, ): Promise { - const { accessToken } = getTokens(); + const accessToken = getCookie('access_token'); const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record), @@ -77,25 +162,19 @@ export async function api( headers['Authorization'] = `Bearer ${accessToken}`; } - let res = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); + let res = await request(endpoint, options, headers); if (res.status === 401 && accessToken) { const newToken = await refreshAccessToken(); if (newToken) { headers['Authorization'] = `Bearer ${newToken}`; - res = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); + res = await request(endpoint, options, headers); } else { clearTokens(); if (typeof window !== 'undefined') { window.location.href = '/login'; } - throw new Error('Session expired'); + throw { code: 'session_expired', message: 'Session expired' } satisfies ApiError; } } @@ -114,16 +193,13 @@ export async function api( // Auth API export const authApi = { login: (email: string, password: string) => - api<{ accessToken: string; refreshToken: string; user: any }>('/auth/login', { + api<{ accessToken: string; refreshToken: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), }), logout: () => { - const refreshToken = document.cookie - .split('; ') - .find((row) => row.startsWith('refresh_token=')) - ?.split('=')[1]; + const refreshToken = getCookie('refresh_token'); clearTokens(); if (refreshToken) { api('/auth/logout', { @@ -137,20 +213,20 @@ export const authApi = { // Tabs API export const tabsApi = { list: (venueId: string) => - api<{ tabs: any[] }>(`/venues/${venueId}/tabs`), + api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`), }; // Devices API export const devicesApi = { get: (venueId: string) => - api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | null }>(`/venues/${venueId}/devices`), + api<{ device: Device | null }>(`/venues/${venueId}/devices`), generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'), }; // Users API export const usersApi = { list: (venueId: string) => - api<{ users: any[] }>(`/venues/${venueId}/users`), + api<{ users: User[] }>(`/venues/${venueId}/users`), create: (venueId: string, data: { email: string; password: string; name: string; role: string }) => api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }), }; @@ -163,7 +239,7 @@ export const commandsApi = { if (params?.limit) searchParams.set('limit', String(params.limit)); if (params?.offset) searchParams.set('offset', String(params.offset)); const qs = searchParams.toString(); - return api<{ commands: any[]; total: number }>( + return api<{ commands: Command[]; total: number }>( `/venues/${venueId}/commands${qs ? `?${qs}` : ''}`, ); }, @@ -176,13 +252,12 @@ export const commandsApi = { // Venues API export const venuesApi = { - list: () => api<{ id: string; name: string }[]>('/venues'), get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`), }; // Planned Tabs API export const plannedTabsApi = { - list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`), + list: (venueId: string) => api<{ plannedTabs: PlannedTab[] }>(`/venues/${venueId}/planned-tabs`), create: (venueId: string, customerName: string, email?: string) => api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }), update: (venueId: string, id: string, customerName: string, email?: string) => @@ -199,11 +274,11 @@ export const plannedTabsApi = { // Reminders API export const remindersApi = { candidates: (venueId: string) => - api<{ candidates: any[] }>(`/venues/${venueId}/reminders/candidates`), + api<{ candidates: ReminderCandidate[] }>(`/venues/${venueId}/reminders/candidates`), send: (venueId: string) => api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }), history: (venueId: string) => - api<{ reminders: any[] }>(`/venues/${venueId}/reminders`), + api<{ reminders: Reminder[] }>(`/venues/${venueId}/reminders`), sendTest: (venueId: string, plannedTabId: string) => api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }), }; @@ -211,5 +286,5 @@ export const remindersApi = { export { setTokens, clearTokens, refreshAccessToken }; export function getAccessToken(): string | null { - return getTokens().accessToken ?? null; -} \ No newline at end of file + return getCookie('access_token'); +} diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index 0b56baa..8f3ba99 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -23,6 +23,24 @@ const translations: Record> = { 'common.close': 'Close', 'common.copied': 'Copied', 'common.logged_out': 'Logged out', + 'errors.invalid_credentials': 'Invalid email or password', + 'errors.too_many_requests': 'Too many attempts. Wait a minute and try again.', + 'errors.validation_error': 'Some fields are not filled in correctly', + 'errors.version_mismatch': 'The tab changed since it was loaded. Check it and try again.', + 'errors.command_already_pending': 'A close request for this tab is already waiting for the terminal', + 'errors.tab_not_owned': 'This tab is no longer open on the terminal', + 'errors.device_revoked': 'The terminal for this tab has been unpaired', + 'errors.name_exists': 'This customer already exists', + 'errors.email_exists': 'A user with this email already exists', + 'errors.resend_not_configured': 'Sending email is not set up on the server', + 'errors.reminders_in_progress': 'Reminders are already being sent', + 'errors.no_email': 'This customer has no email address', + 'errors.forbidden': "You don't have permission to do this", + 'errors.venue_forbidden': "You don't have permission to do this", + 'errors.internal_error': 'Something went wrong on the server', + 'errors.network_error': "Can't reach the server. Check the connection.", + 'errors.session_expired': 'Your session has expired. Log in again.', + 'errors.unknown': 'An error occurred', 'status_page.home': 'Back to KoolTap', 'not_found.title': 'Page not found', 'not_found.desc': "This page doesn't exist or has been moved.", @@ -56,7 +74,13 @@ const translations: Record> = { 'tabs.email': 'Email', 'tabs.amount': 'Amount', 'tabs.items': 'Items', - 'tabs.version': 'Version', + 'tabs.search_placeholder': 'Search customers…', + 'tabs.sort': 'Sort by', + 'tabs.sort_name': 'Name', + 'tabs.sort_amount': 'Highest amount', + 'tabs.outstanding': '{amount} outstanding', + 'tabs.closing': 'Closing…', + 'tabs.no_matches': 'No tabs match "{query}"', 'tabs.reported': 'Last updated', 'tabs.actions': 'Actions', 'tabs.close': 'Close', @@ -140,7 +164,8 @@ const translations: Record> = { 'commands.load_failed': 'Failed to load commands', 'commands.empty': 'No commands found', 'commands.empty_desc': 'Commands appear here when you send close requests.', - 'commands.tab': 'Tab', + 'commands.customer': 'Customer', + 'commands.amount': 'Amount', 'commands.requested': 'Requested By', 'commands.payment': 'Payment', 'commands.created': 'Created', @@ -157,7 +182,6 @@ const translations: Record> = { 'settings.device': 'Terminal', 'settings.paired': 'Paired as "{name}" — last seen {time}', 'settings.not_paired': 'No terminal paired yet', - 'settings.connected': 'Connected', 'settings.since': 'Since {date}', 'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.', 'settings.pair': 'Pair Terminal', @@ -178,7 +202,8 @@ const translations: Record> = { 'overview.title': 'Overview', 'overview.open_tabs': 'Open Tabs', 'overview.device': 'Terminal', - 'overview.pending': 'Pending Commands', + 'overview.pending': 'Waiting for terminal', + 'overview.outstanding': 'Outstanding', 'overview.recent': 'Recent Commands', 'overview.no_commands': 'No commands yet', 'overview.not_connected': 'Not connected', @@ -199,6 +224,7 @@ const translations: Record> = { 'reminders.no_candidates_desc': 'Make sure planned customers have an email address.', 'reminders.send': 'Send Reminders', 'reminders.sending': 'Sending...', + 'reminders.admin_only': 'Only admins can send reminders.', 'reminders.confirm': 'This emails a payment reminder to {count} customer(s) with an unpaid tab.', 'reminders.sent_title': 'Reminders Sent', 'reminders.partial': '{sent} of {total} sent', @@ -233,6 +259,24 @@ const translations: Record> = { 'common.close': 'Sluiten', 'common.copied': 'Gekopieerd', 'common.logged_out': 'Uitgelogd', + 'errors.invalid_credentials': 'Ongeldig e-mailadres of wachtwoord', + 'errors.too_many_requests': 'Te veel pogingen. Wacht een minuutje en probeer opnieuw.', + 'errors.validation_error': 'Sommige velden zijn niet correct ingevuld', + 'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.', + 'errors.command_already_pending': 'Er wacht al een sluitverzoek voor deze poef op de kassa', + 'errors.tab_not_owned': 'Deze poef staat niet meer open op de kassa', + 'errors.device_revoked': 'De kassa van deze poef is ontkoppeld', + 'errors.name_exists': 'Deze klant bestaat al', + 'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres', + 'errors.resend_not_configured': 'E-mail versturen is niet ingesteld op de server', + 'errors.reminders_in_progress': 'Herinneringen worden al verstuurd', + 'errors.no_email': 'Deze klant heeft geen e-mailadres', + 'errors.forbidden': 'Je hebt geen toestemming om dit te doen', + 'errors.venue_forbidden': 'Je hebt geen toestemming om dit te doen', + 'errors.internal_error': 'Er ging iets mis op de server', + 'errors.network_error': 'Kan de server niet bereiken. Controleer de verbinding.', + 'errors.session_expired': 'Je sessie is verlopen. Log opnieuw in.', + 'errors.unknown': 'Er is een fout opgetreden', 'status_page.home': 'Terug naar KoolTap', 'not_found.title': 'Pagina niet gevonden', 'not_found.desc': 'Deze pagina bestaat niet of is verplaatst.', @@ -266,7 +310,13 @@ const translations: Record> = { 'tabs.email': 'E-mail', 'tabs.amount': 'Bedrag', 'tabs.items': 'Items', - 'tabs.version': 'Versie', + 'tabs.search_placeholder': 'Zoek klant…', + 'tabs.sort': 'Sorteer op', + 'tabs.sort_name': 'Naam', + 'tabs.sort_amount': 'Hoogste bedrag', + 'tabs.outstanding': '{amount} openstaand', + 'tabs.closing': 'Wordt gesloten…', + 'tabs.no_matches': 'Geen poefs gevonden voor "{query}"', 'tabs.reported': 'Laatst bijgewerkt', 'tabs.actions': 'Acties', 'tabs.close': 'Sluiten', @@ -350,7 +400,8 @@ const translations: Record> = { 'commands.load_failed': 'Opdrachten konden niet geladen worden', 'commands.empty': 'Geen opdrachten gevonden', 'commands.empty_desc': 'Opdrachten verschijnen hier zodra je een sluitverzoek verstuurt.', - 'commands.tab': 'Poef', + 'commands.customer': 'Klant', + 'commands.amount': 'Bedrag', 'commands.requested': 'Aangevraagd door', 'commands.payment': 'Betaling', 'commands.created': 'Aangemaakt', @@ -367,7 +418,6 @@ const translations: Record> = { 'settings.device': 'Terminal', 'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}', 'settings.not_paired': 'Nog geen terminal gekoppeld', - 'settings.connected': 'Verbonden', 'settings.since': 'Sinds {date}', 'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.', 'settings.pair': 'Terminal koppelen', @@ -388,7 +438,8 @@ const translations: Record> = { 'overview.title': 'Overzicht', 'overview.open_tabs': 'Open poefs', 'overview.device': 'Terminal', - 'overview.pending': 'Openstaande opdrachten', + 'overview.pending': 'Wachtend op kassa', + 'overview.outstanding': 'Openstaand', 'overview.recent': 'Recente opdrachten', 'overview.no_commands': 'Nog geen opdrachten', 'overview.not_connected': 'Niet verbonden', @@ -409,6 +460,7 @@ const translations: Record> = { 'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.', 'reminders.send': 'Herinneringen versturen', 'reminders.sending': 'Bezig met versturen...', + 'reminders.admin_only': 'Alleen beheerders kunnen herinneringen versturen.', 'reminders.confirm': 'Er wordt een betalingsherinnering gemaild naar {count} klant(en) met een openstaande poef.', 'reminders.sent_title': 'Herinneringen verstuurd', 'reminders.partial': '{sent} van {total} verstuurd', @@ -436,6 +488,8 @@ interface LangContextType { t: (key: string, params?: Params) => string; /** Uses `_plural` unless `count` is 1; `{count}` is filled in. */ tn: (key: string, count: number, params?: Params) => string; + /** An API error in the current language: by its code when translated, else its message. */ + te: (error: unknown, fallbackKey?: string) => string; /** "just now", "5m ago"…, falling back to a date after a week. */ relativeTime: (date: string | Date) => string; dateTime: (date: string | Date) => string; @@ -459,6 +513,12 @@ function helpers(lang: Lang): Omit { lang, t, tn: (key, count, params) => t(count === 1 ? key : `${key}_plural`, { count, ...params }), + te: (error, fallbackKey = 'common.unknown_error') => { + const { code, message } = (error ?? {}) as { code?: string; message?: string }; + const key = `errors.${code}`; + if (code && (translations[lang][key] ?? translations.en[key])) return t(key); + return message || t(fallbackKey); + }, relativeTime: (value) => { const date = new Date(value); const seconds = Math.floor((Date.now() - date.getTime()) / 1000); diff --git a/apps/dashboard/src/lib/session.tsx b/apps/dashboard/src/lib/session.tsx new file mode 100644 index 0000000..863b51b --- /dev/null +++ b/apps/dashboard/src/lib/session.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { createContext, useContext, ReactNode } from 'react'; +import { getCookie, type Role } from '@/lib/api/client'; + +export interface Session { + userId: string; + email: string; + role: Role; + venueId: string; +} + +/** The signed-in user from the access token cookie, or null when there is none or it can't be read. */ +export function readSession(): Session | null { + const token = getCookie('access_token'); + if (!token) return null; + try { + const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(atob(base64)); + if (!payload.venueId || !payload.role) return null; + return { userId: payload.sub, email: payload.email, role: payload.role, venueId: payload.venueId }; + } catch { + return null; + } +} + +const SessionContext = createContext(null); + +export function SessionProvider({ session, children }: { session: Session; children: ReactNode }) { + return {children}; +} + +export function useSession(): Session { + const session = useContext(SessionContext); + if (!session) throw new Error('useSession must be used inside the dashboard layout'); + return session; +} diff --git a/apps/dashboard/src/lib/utils.ts b/apps/dashboard/src/lib/utils.ts index 396de14..eb7c69d 100644 --- a/apps/dashboard/src/lib/utils.ts +++ b/apps/dashboard/src/lib/utils.ts @@ -1,6 +1,6 @@ export function formatCents(cents: number, currency: string = 'EUR'): string { const euros = cents / 100; - return new Intl.NumberFormat('nl-NL', { + return new Intl.NumberFormat('nl-BE', { style: 'currency', currency, }).format(euros); diff --git a/apps/dashboard/tsconfig.tsbuildinfo b/apps/dashboard/tsconfig.tsbuildinfo deleted file mode 100644 index a4cdbbf..0000000 --- a/apps/dashboard/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/styled-jsx/types/css.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/global.d.ts","../../node_modules/.pnpm/csstype@3.2.3/node_modules/csstype/index.d.ts","../../node_modules/.pnpm/@types+prop-types@15.7.15/node_modules/@types/prop-types/index.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/styled-jsx/types/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/styled-jsx/types/macro.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/styled-jsx/types/style.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/styled-jsx/types/global.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/amp.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/amp.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/index.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/globals.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/abortcontroller.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/domexception.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/events.d.ts","../../node_modules/.pnpm/buffer@5.7.1/node_modules/buffer/index.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/header.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/readable.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/file.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/fetch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/formdata.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/connector.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-origin.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool-stats.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/handlers.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-handler.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/api.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/interceptors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/util.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cookies.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/patch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/websocket.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/eventsource.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/filereader.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/content-type.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cache.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/index.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/fetch.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/assert.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/assert/strict.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/async_hooks.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/buffer.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/child_process.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/cluster.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/console.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/constants.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/crypto.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dgram.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dns.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dns/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/domain.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/events.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/fs.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/fs/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/http.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/http2.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/https.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/inspector.generated.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/module.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/net.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/os.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/path.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/perf_hooks.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/process.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/punycode.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/querystring.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/readline.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/readline/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/repl.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/sea.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/consumers.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/web.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/string_decoder.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/test.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/timers.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/timers/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/tls.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/trace_events.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/tty.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/url.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/util.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/v8.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/vm.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/wasi.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/worker_threads.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/zlib.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/get-page-files.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/canary.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/experimental.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/index.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/canary.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/experimental.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/compiled/webpack/webpack.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/config.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/load-custom-routes.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/image-config.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/body-streams.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-kind.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-definitions/route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-matches/route-match.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/app-router-headers.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/request-meta.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/revalidate.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/config-shared.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/base-http/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/api-utils/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/node-environment.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/require-hook.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/node-polyfill-crypto.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/page-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/analysis/get-page-static-info.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/render-result.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/helpers/i18n-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/next-url.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/cookies.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/request.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/response.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/setup-exception-listeners.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/constants.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/base-http/node.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/font-utils.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-modules/route-module.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/deep-readonly.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/load-components.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-definitions/locale-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-definitions/pages-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/mitt.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/with-router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/route-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/page-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/bloom-filter.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/router/router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-definitions/app-page-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/constants.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/page-extensions-type.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/loaders/next-app-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/app-dir-module.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/response-cache/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/response-cache/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/incremental-cache/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/hooks-server-context.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/static-generation-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/static-generation-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/request-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/request-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/app-render/create-error-handler.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/app-render/app-render.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-modules/app-page/vendored/contexts/entrypoints.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-modules/app-page/module.compiled.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/jsx-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/error-boundary.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/router-reducer/create-initial-router-state.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/app-router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/layout-router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/render-from-template-context.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/action-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/action-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/client-page.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/search-params.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/not-found-boundary.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/app-render/rsc/preloads.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/app-render/rsc/postpone.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/app-render/rsc/taint.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/app-render/entry-base.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/templates/app-page.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-modules/app-page/module.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/builtin-request-context.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/app-render/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-modules/pages/vendored/contexts/entrypoints.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-modules/pages/module.compiled.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/templates/pages.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-modules/pages/module.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/render.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-definitions/pages-api-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-matches/pages-api-route-match.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-matchers/route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-matcher-providers/route-matcher-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/route-matcher-managers/route-matcher-manager.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/locale-route-normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/request/pathname-normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/request/suffix.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/request/rsc.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/request/prefix.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/request/postponed.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/request/action.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/request/prefetch-rsc.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/future/normalizers/request/next-data.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/base-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/image-optimizer.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/next-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/coalesced-function.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/router-utils/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/trace/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/trace/trace.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/trace/shared.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/trace/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/load-jsconfig.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack-config.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/webpack/plugins/define-env-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/build/swc/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/dev/parse-version-info.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/dev/hot-reloader-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/telemetry/storage.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/render-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/router-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/lib/dev-bundler-service.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/dev/static-paths-worker.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/dev/next-dev-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/next.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/metadata/types/extra-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/types/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","../../node_modules/.pnpm/@next+env@14.2.35/node_modules/@next/env/dist/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/utils.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/pages/_app.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/app.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/cache.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/runtime-config.external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/config.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/pages/_document.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/document.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/dynamic.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dynamic.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/pages/_error.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/error.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/head.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/head.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/draft-mode.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/get-img-props.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/image-component.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/shared/lib/image-external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/image.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/link.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/link.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/redirect-status-code.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/redirect.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/not-found.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/navigation.react-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/components/navigation.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/navigation.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/client/script.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/script.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/server/web/spec-extension/image-response.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/compiled/@vercel/og/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/types/global.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/types/compiled.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/image-types/global.d.ts","./next-env.d.ts","./src/app/manifest.ts","./src/lib/site.ts","./src/app/robots.ts","./src/app/sitemap.ts","../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/clsx.d.mts","../../node_modules/.pnpm/tailwind-merge@2.6.1/node_modules/tailwind-merge/dist/types.d.ts","./src/lib/cn.ts","../../node_modules/.pnpm/xlsx@https+++cdn.sheetjs.com+xlsx-0.20.3+xlsx-0.20.3.tgz/node_modules/xlsx/types/index.d.ts","./src/lib/customer-import.ts","./src/lib/device-status.ts","./src/lib/utils.ts","./src/lib/api/client.ts","../../node_modules/.pnpm/@radix-ui+react-slot@1.3.3_@types+react@18.3.31_react@18.3.1/node_modules/@radix-ui/react-slot/dist/index.d.mts","../../node_modules/.pnpm/class-variance-authority@0.7.1/node_modules/class-variance-authority/dist/types.d.ts","../../node_modules/.pnpm/class-variance-authority@0.7.1/node_modules/class-variance-authority/dist/index.d.ts","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/status-page.tsx","./src/lib/i18n.tsx","./src/app/error.tsx","./src/app/global-error.tsx","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/compiled/@next/font/dist/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/dist/compiled/@next/font/dist/google/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._98a696deb7c80dd23889070af0bc2eb4/node_modules/next/font/google/index.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/environmentmanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/subscribable-cbifvtkz.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/focusmanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/removable-demjhw5m.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/hydration-cq7qyazb.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/infinitequeryobserver.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/notifymanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/onlinemanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/queriesobserver.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/timeoutmanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/streamedquery.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/index.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/hydrationboundary.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/isrestoringprovider.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/queryclientprovider.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/queryerrorresetboundary.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/types.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usequeries.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/queryoptions.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usesuspensequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usesuspenseinfinitequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usesuspensequeries.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/useprefetchquery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/useprefetchinfinitequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/infinitequeryoptions.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/useisfetching.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usemutationstate.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usemutation.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/mutationoptions.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/useinfinitequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/index.d.ts","./src/app/providers.tsx","./src/app/layout.tsx","./src/app/not-found.tsx","./src/app/page.tsx","../../node_modules/.pnpm/lucide-react@0.309.0_react@18.3.1/node_modules/lucide-react/dist/lucide-react.d.ts","./src/components/language-selector.tsx","../../node_modules/.pnpm/@radix-ui+react-context@1.2_7e5a4e8f36c561d39949339c7011401d/node_modules/@radix-ui/react-context/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-primitive@2_dba614f83980a1ab9805a3f12c0d2bc2/node_modules/@radix-ui/react-primitive/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-dismissable_4f2024a88a120e800aa2287adeb24710/node_modules/@radix-ui/react-dismissable-layer/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-toast@1.2.2_b178c2c4a82f5fa77750a59f509799ce/node_modules/@radix-ui/react-toast/dist/index.d.mts","./src/components/ui/toast.tsx","../../node_modules/.pnpm/@socket.io+component-emitter@3.1.2/node_modules/@socket.io/component-emitter/lib/cjs/index.d.ts","../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/esm/commons.d.ts","../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/esm/encodepacket.d.ts","../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/esm/decodepacket.d.ts","../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/esm/index.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transport.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/globals.node.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/socket.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transports/polling.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transports/polling-xhr.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transports/polling-xhr.node.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transports/websocket.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transports/websocket.node.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transports/webtransport.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transports/index.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/util.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/contrib/parseuri.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/transports/polling-fetch.d.ts","../../node_modules/.pnpm/engine.io-client@6.6.7_supports-color@8.1.1/node_modules/engine.io-client/build/esm/index.d.ts","../../node_modules/.pnpm/socket.io-parser@4.2.7_supports-color@8.1.1/node_modules/socket.io-parser/build/esm/index.d.ts","../../node_modules/.pnpm/socket.io-client@4.8.4_supports-color@8.1.1/node_modules/socket.io-client/build/esm/socket.d.ts","../../node_modules/.pnpm/socket.io-client@4.8.4_supports-color@8.1.1/node_modules/socket.io-client/build/esm/manager.d.ts","../../node_modules/.pnpm/socket.io-client@4.8.4_supports-color@8.1.1/node_modules/socket.io-client/build/esm/index.d.ts","./src/lib/realtime.tsx","./src/app/dashboard/layout.tsx","./src/components/page-header.tsx","./src/components/ui/skeleton.tsx","./src/components/loading-state.tsx","./src/components/ui/badge.tsx","./src/components/command-status-badge.tsx","./src/app/dashboard/page.tsx","./src/components/ui/table.tsx","./src/components/payment-method-badge.tsx","./src/components/empty-state.tsx","./src/components/error-state.tsx","./src/app/dashboard/commands/page.tsx","../../node_modules/.pnpm/@radix-ui+react-focus-scope_161760c0d51338f080e5b66a9ea35f08/node_modules/@radix-ui/react-focus-scope/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-portal@1.1._35c248e3d41b4a33144a8517e72175a4/node_modules/@radix-ui/react-portal/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-dialog@1.1._89c2a39b94a39c3c4bfcbd95c00244cd/node_modules/@radix-ui/react-dialog/dist/index.d.mts","./src/components/ui/dialog.tsx","./src/app/dashboard/reminders/page.tsx","./src/app/dashboard/settings/page.tsx","./src/components/ui/input.tsx","../../node_modules/.pnpm/@radix-ui+react-label@2.1.1_da1c8461c5aa9a0df92eac209c865a77/node_modules/@radix-ui/react-label/dist/index.d.mts","./src/components/ui/label.tsx","../../node_modules/.pnpm/@radix-ui+react-roving-focu_d5b0f6117fe35925e77b051a5d0d3813/node_modules/@radix-ui/react-roving-focus/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-radio-group_329e69a3bae96a8187bc0f963334324d/node_modules/@radix-ui/react-radio-group/dist/index.d.mts","./src/components/ui/radio-group.tsx","./src/components/import-customers-dialog.tsx","./src/app/dashboard/tabs/page.tsx","../../node_modules/.pnpm/@radix-ui+react-arrow@1.1.1_4adf567bd0450ecb7dbf3f22f0548dcb/node_modules/@radix-ui/react-arrow/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+rect@1.1.3/node_modules/@radix-ui/rect/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-popper@1.3._eb61390ffeb98719c4501fa966467838/node_modules/@radix-ui/react-popper/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-select@2.3._17810f902be7f268a61046ec24c8ec35/node_modules/@radix-ui/react-select/dist/index.d.mts","./src/components/ui/select.tsx","./src/app/dashboard/users/page.tsx","./src/app/login/page.tsx","./src/components/confirm-dialog.tsx","./src/components/device-status-badge.tsx","./.next/types/app/page.ts","./.next/types/app/dashboard/page.ts","./.next/types/app/dashboard/commands/page.ts","./.next/types/app/dashboard/reminders/page.ts","./.next/types/app/dashboard/settings/page.ts","./.next/types/app/dashboard/tabs/page.ts","./.next/types/app/dashboard/users/page.ts","./.next/types/app/login/page.ts"],"fileIdsList":[[99,146,361,514],[99,146,361,509],[99,146,361,519],[99,146,361,520],[99,146,361,528],[99,146,361,534],[99,146,361,535],[99,146,361,471],[99,146,409,410],[87,99,146,396,423,427,430,467,472,502,504,506,507,508,510,511,512,513],[87,99,146,390,396,418,423,427,430,472,473,478,502],[87,99,146,396,423,428,430,467,472,502,504,505,506,508],[87,99,146,396,422,423,427,428,430,467,472,478,504,506,507,510,512,513,518],[87,99,146,396,423,427,428,430,467,472,478,504,507,518],[87,99,146,396,422,423,427,428,430,467,472,478,502,504,506,510,512,513,518,521,523,526,527],[87,99,146,396,423,427,430,467,472,478,504,506,507,510,512,513,518,521,523,533],[87,99,146,427,429,430],[99,146],[99,146,409,413,435,468],[87,99,146,396,423,427,428,430,473,521,523],[99,146,409],[99,146,429,430],[87,99,146,396,423],[87,99,146,430,467],[99,146,409,413],[99,146,430,507],[99,146,427,518],[99,146,421,430,507],[99,146,428],[99,146,428,430],[87,99,146,420,423,427,430,472,478,510,518,523,526],[99,146,430,472],[99,146,428,505],[87,99,146],[87,99,146,390,427,428],[87,99,146,418,426],[87,99,146,418,424,426],[87,99,146,418],[87,99,146,418,472,517],[87,99,146,418,426,522],[87,99,146,418,472,525],[87,99,146,418,472,532],[87,99,146,418,426,472,477],[99,146,416,417],[99,146,419],[87,99,146,423,467,501],[99,146,418],[87,99,146,475],[87,99,146,474,475,476,515,516],[87,99,146,474,475,529,530],[87,99,146,287,474,475,524],[87,99,146,474,475],[87,99,146,287,474,475,476,515,516,531],[87,99,146,474,475,476],[99,146,437],[99,146,437,439],[99,146,436,438,440,441,442,443,444,445,446],[99,146,437,440],[99,146,440],[87,99,146,447],[99,146,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466],[99,146,447,452],[87,99,146,287],[99,146,447],[99,146,447,452,461],[99,146,447,452,454],[99,143,146],[99,145,146],[146],[99,146,151,179],[99,146,147,152,157,165,176,187],[99,146,147,148,157,165],[94,95,96,99,146],[99,146,149,188],[99,146,150,151,158,166],[99,146,151,176,184],[99,146,152,154,157,165],[99,145,146,153],[99,146,154,155],[99,146,156,157],[99,145,146,157],[99,146,157,158,159,176,187],[99,146,157,158,159,172,176,179],[99,146,154,157,160,165,176,187],[99,146,157,158,160,161,165,176,184,187],[99,146,160,162,176,184,187],[97,98,99,100,101,102,103,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193],[99,146,157,163],[99,146,164,187,192],[99,146,154,157,165,176],[99,146,166],[99,146,167],[99,145,146,168],[99,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193],[99,146,170],[99,146,171],[99,146,157,172,173],[99,146,172,174,188,190],[99,146,157,176,177,179],[99,146,178,179],[99,146,176,177],[99,146,179],[99,146,180],[99,143,146,176,181],[99,146,157,182,183],[99,146,182,183],[99,146,151,165,176,184],[99,146,185],[99,146,165,186],[99,146,160,171,187],[99,146,151,188],[99,146,176,189],[99,146,164,190],[99,146,191],[99,141,146],[99,141,146,157,159,168,176,179,187,190,192],[99,146,176,193],[87,99,146,198,199,200],[87,99,146,198,199],[87,91,99,146,197,362,405],[87,91,99,146,196,362,405],[84,85,86,99,146],[99,146,416,425],[99,146,416],[99,146,484,485,486,488,489,490,491,492,493,494,495,496],[99,146,479,483,484,485],[99,146,479,483,486],[99,146,489,491,492],[99,146,487],[99,146,479,483,485,486,487],[99,146,488],[99,146,484],[99,146,483,484],[99,146,483,490],[99,146,480],[99,146,480,481,482],[92,99,146],[99,146,366],[99,146,368,369,370],[99,146,372],[99,146,203,213,219,221,362],[99,146,203,210,212,215,233],[99,146,213],[99,146,213,215,340],[99,146,268,286,301,408],[99,146,310],[99,146,203,213,220,254,264,337,338,408],[99,146,220,408],[99,146,213,264,265,266,408],[99,146,213,220,254,408],[99,146,408],[99,146,203,220,221,408],[99,146,294],[99,145,146,194,293],[87,99,146,287,288,289,307,308],[99,146,277],[99,146,276,278,382],[87,99,146,287,288,305],[99,146,283,308,394],[99,146,392,393],[99,146,227,391],[99,146,280],[99,145,146,194,227,243,276,277,278,279],[87,99,146,305,307,308],[99,146,305,307],[99,146,305,306,308],[99,146,171,194],[99,146,275],[99,145,146,194,212,214,271,272,273,274],[87,99,146,204,385],[87,99,146,187,194],[87,99,146,220,252],[87,99,146,220],[99,146,250,255],[87,99,146,251,365],[99,146,433],[87,91,99,146,160,194,196,197,362,403,404],[99,146,362],[99,146,202],[99,146,355,356,357,358,359,360],[99,146,357],[87,99,146,251,287,365],[87,99,146,287,363,365],[87,99,146,287,365],[99,146,160,194,214,365],[99,146,160,194,211,212,223,241,243,275,280,281,303,305],[99,146,272,275,280,288,290,291,292,294,295,296,297,298,299,300,408],[99,146,273],[87,99,146,171,194,212,213,241,243,244,246,271,303,304,308,362,408],[99,146,160,194,214,215,227,228,276],[99,146,160,194,213,215],[99,146,160,176,194,211,214,215],[99,146,160,171,187,194,211,212,213,214,215,220,223,224,234,235,237,240,241,243,244,245,246,270,271,304,305,313,315,318,320,323,325,326,327,328],[99,146,160,176,194],[99,146,203,204,205,211,212,362,365,408],[99,146,160,176,187,194,208,339,341,342,408],[99,146,171,187,194,208,211,214,231,235,237,238,239,244,271,318,329,331,337,351,352],[99,146,213,217,271],[99,146,211,213],[99,146,224,319],[99,146,321,322],[99,146,321],[99,146,319],[99,146,321,324],[99,146,207,208],[99,146,207,247],[99,146,207],[99,146,209,224,317],[99,146,316],[99,146,208,209],[99,146,209,314],[99,146,208],[99,146,303],[99,146,160,194,211,223,242,262,268,282,285,302,305],[99,146,256,257,258,259,260,261,283,284,308,363],[99,146,312],[99,146,160,194,211,223,242,248,309,311,313,362,365],[99,146,160,187,194,204,211,213,270],[99,146,267],[99,146,160,194,345,350],[99,146,234,243,270,365],[99,146,333,337,351,354],[99,146,160,217,337,345,346,354],[99,146,203,213,234,245,348],[99,146,160,194,213,220,245,332,333,343,344,347,349],[99,146,195,241,242,243,362,365],[99,146,160,171,187,194,209,211,212,214,217,222,223,231,234,235,237,238,239,240,244,246,270,271,315,329,330,365],[99,146,160,194,211,213,217,331,353],[99,146,160,194,212,214],[87,99,146,160,171,194,202,204,211,212,215,223,240,241,243,244,246,312,362,365],[99,146,160,171,187,194,206,209,210,214],[99,146,207,269],[99,146,160,194,207,212,223],[99,146,160,194,213,224],[99,146,160,194],[99,146,227],[99,146,226],[99,146,228],[99,146,213,225,227,231],[99,146,213,225,227],[99,146,160,194,206,213,214,220,228,229,230],[87,99,146,305,306,307],[99,146,263],[87,99,146,204],[87,99,146,237],[87,99,146,195,240,243,246,362,365],[99,146,204,385,386],[87,99,146,255],[87,99,146,171,187,194,202,249,251,253,254,365],[99,146,214,220,237],[99,146,236],[87,99,146,158,160,171,194,202,255,264,362,363,364],[83,87,88,89,90,99,146,196,197,362,405],[99,146,151],[99,146,334,335,336],[99,146,334],[99,146,374],[99,146,376],[99,146,378],[99,146,434],[99,146,380],[99,146,383],[99,146,387],[91,93,99,146,362,367,371,373,375,377,379,381,384,388,390,396,397,399,406,407,408],[99,146,389],[99,146,395],[99,146,251],[99,146,398],[99,145,146,228,229,230,231,400,401,402,405],[99,146,194],[87,91,99,146,160,162,171,194,196,197,198,200,202,215,354,361,365,405],[99,146,497,498,499,500],[99,146,479,497,498,499],[99,146,479,498,500],[99,146,479],[99,113,117,146,187],[99,113,146,176,187],[99,108,146],[99,110,113,146,184,187],[99,146,165,184],[99,108,146,194],[99,110,113,146,165,187],[99,105,106,109,112,146,157,176,187],[99,113,120,146],[99,105,111,146],[99,113,134,135,146],[99,109,113,146,179,187,194],[99,134,146,194],[99,107,108,146,194],[99,113,146],[99,107,108,109,110,111,112,113,114,115,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,135,136,137,138,139,140,146],[99,113,128,146],[99,113,120,121,146],[99,111,113,121,122,146],[99,112,146],[99,105,108,113,146],[99,113,117,121,122,146],[99,117,146],[99,111,113,116,146,187],[99,105,110,113,120,146],[99,146,176],[99,108,113,134,146,192,194]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"0990a7576222f248f0a3b888adcb7389f957928ce2afb1cd5128169086ff4d29","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"cc69795d9954ee4ad57545b10c7bf1a7260d990231b1685c147ea71a6faa265c","impliedFormat":1},{"version":"8bc6c94ff4f2af1f4023b7bb2379b08d3d7dd80c698c9f0b07431ea16101f05f","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"57194e1f007f3f2cbef26fa299d4c6b21f4623a2eddc63dfeef79e38e187a36e","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"8caa5c86be1b793cd5f599e27ecb34252c41e011980f7d61ae4989a149ff6ccc","impliedFormat":1},{"version":"f9fd93190acb1ffe0bc0fb395df979452f8d625071e9ffc8636e4dfb86ab2508","impliedFormat":1},{"version":"5f41fd8732a89e940c58ce22206e3df85745feb8983e2b4c6257fb8cbb118493","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"1cfa8647d7d71cb03847d616bd79320abfc01ddea082a49569fda71ac5ece66b","impliedFormat":1},{"version":"bb7a61dd55dc4b9422d13da3a6bb9cc5e89be888ef23bbcf6558aa9726b89a1c","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"cfe4ef4710c3786b6e23dae7c086c70b4f4835a2e4d77b75d39f9046106e83d3","impliedFormat":1},{"version":"cbea99888785d49bb630dcbb1613c73727f2b5a2cf02e1abcaab7bcf8d6bf3c5","impliedFormat":1},{"version":"3a8bddb66b659f6bd2ff641fc71df8a8165bafe0f4b799cc298be5cd3755bb20","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"2dad084c67e649f0f354739ec7df7c7df0779a28a4f55c97c6b6883ae850d1ce","impliedFormat":1},{"version":"fa5bbc7ab4130dd8cdc55ea294ec39f76f2bc507a0f75f4f873e38631a836ca7","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"cf86de1054b843e484a3c9300d62fbc8c97e77f168bbffb131d560ca0474d4a8","impliedFormat":1},{"version":"196c960b12253fde69b204aa4fbf69470b26daf7a430855d7f94107a16495ab0","impliedFormat":1},{"version":"ee15ea5dd7a9fc9f5013832e5843031817a880bf0f24f37a29fd8337981aae07","impliedFormat":1},{"version":"bf24f6d35f7318e246010ffe9924395893c4e96d34324cde77151a73f078b9ad","impliedFormat":1},{"version":"ea53732769832d0f127ae16620bd5345991d26bf0b74e85e41b61b27d74ea90f","impliedFormat":1},{"version":"10595c7ff5094dd5b6a959ccb1c00e6a06441b4e10a87bc09c15f23755d34439","impliedFormat":1},{"version":"9620c1ff645afb4a9ab4044c85c26676f0a93e8c0e4b593aea03a89ccb47b6d0","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"08ed0b3f0166787f84a6606f80aa3b1388c7518d78912571b203817406e471da","impliedFormat":1},{"version":"47e5af2a841356a961f815e7c55d72554db0c11b4cba4d0caab91f8717846a94","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"f5f541902bf7ae0512a177295de9b6bcd6809ea38307a2c0a18bfca72212f368","impliedFormat":1},{"version":"b0decf4b6da3ebc52ea0c96095bdfaa8503acc4ac8e9081c5f2b0824835dd3bd","impliedFormat":1},{"version":"ca1b882a105a1972f82cc58e3be491e7d750a1eb074ffd13b198269f57ed9e1b","impliedFormat":1},{"version":"fc3e1c87b39e5ba1142f27ec089d1966da168c04a859a4f6aab64dceae162c2b","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"61888522cec948102eba94d831c873200aa97d00d8989fdfd2a3e0ee75ec65a2","impliedFormat":1},{"version":"4e10622f89fea7b05dd9b52fb65e1e2b5cbd96d4cca3d9e1a60bb7f8a9cb86a1","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"59bf32919de37809e101acffc120596a9e45fdbab1a99de5087f31fdc36e2f11","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"faa03dffb64286e8304a2ca96dd1317a77db6bfc7b3fb385163648f67e535d77","impliedFormat":1},{"version":"c40c848daad198266370c1c72a7a8c3d18d2f50727c7859fcfefd3ff69a7f288","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"6428e6edd944ce6789afdf43f9376c1f2e4957eea34166177625aaff4c0da1a0","impliedFormat":1},{"version":"ada39cbb2748ab2873b7835c90c8d4620723aedf323550e8489f08220e477c7f","impliedFormat":1},{"version":"6e5f5cee603d67ee1ba6120815497909b73399842254fc1e77a0d5cdc51d8c9c","impliedFormat":1},{"version":"8dba67056cbb27628e9b9a1cba8e57036d359dceded0725c72a3abe4b6c79cd4","impliedFormat":1},{"version":"70f3814c457f54a7efe2d9ce9d2686de9250bb42eb7f4c539bd2280a42e52d33","impliedFormat":1},{"version":"154dd2e22e1e94d5bc4ff7726706bc0483760bae40506bdce780734f11f7ec47","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"0131e203d8560edb39678abe10db42564a068f98c4ebd1ed9ffe7279c78b3c81","impliedFormat":1},{"version":"f6404e7837b96da3ea4d38c4f1a3812c96c9dcdf264e93d5bdb199f983a3ef4b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"8b8f00491431fe82f060dfe8c7f2180a9fb239f3d851527db909b83230e75882","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"903e299a28282fa7b714586e28409ed73c3b63f5365519776bf78e8cf173db36","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"dd3900b24a6a8745efeb7ad27629c0f8a626470ac229c1d73f1fe29d67e44dca","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"ec29be0737d39268696edcec4f5e97ce26f449fa9b7afc2f0f99a86def34a418","impliedFormat":1},{"version":"aeab39e8e0b1a3b250434c3b2bb8f4d17bbec2a9dbce5f77e8a83569d3d2cbc2","impliedFormat":1},{"version":"ec6cba1c02c675e4dd173251b156792e8d3b0c816af6d6ad93f1a55d674591aa","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"d729408dfde75b451530bcae944cf89ee8277e2a9df04d1f62f2abfd8b03c1e1","impliedFormat":1},{"version":"e15d3c84d5077bb4a3adee4c791022967b764dc41cb8fa3cfa44d4379b2c95f5","impliedFormat":1},{"version":"5f58e28cd22e8fc1ac1b3bc6b431869f1e7d0b39e2c21fbf79b9fa5195a85980","impliedFormat":1},{"version":"e1fc1a1045db5aa09366be2b330e4ce391550041fc3e925f60998ca0b647aa97","impliedFormat":1},{"version":"63533978dcda286422670f6e184ac516805a365fb37a086eeff4309e812f1402","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"31fb49ef3aa3d76f0beb644984e01eab0ea222372ea9b49bb6533be5722d756c","impliedFormat":1},{"version":"33cd131e1461157e3e06b06916b5176e7a8ec3fce15a5cfe145e56de744e07d2","impliedFormat":1},{"version":"889ef863f90f4917221703781d9723278db4122d75596b01c429f7c363562b86","impliedFormat":1},{"version":"3556cfbab7b43da96d15a442ddbb970e1f2fc97876d055b6555d86d7ac57dae5","impliedFormat":1},{"version":"437751e0352c6e924ddf30e90849f1d9eb00ca78c94d58d6a37202ec84eb8393","impliedFormat":1},{"version":"48e8af7fdb2677a44522fd185d8c87deff4d36ee701ea003c6c780b1407a1397","impliedFormat":1},{"version":"d11308de5a36c7015bb73adb5ad1c1bdaac2baede4cc831a05cf85efa3cc7f2f","impliedFormat":1},{"version":"38e4684c22ed9319beda6765bab332c724103d3a966c2e5e1c5a49cf7007845f","impliedFormat":1},{"version":"f9812cfc220ecf7557183379531fa409acd249b9e5b9a145d0d52b76c20862de","affectsGlobalScope":true,"impliedFormat":1},{"version":"e650298721abc4f6ae851e60ae93ee8199791ceec4b544c3379862f81f43178c","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"680793958f6a70a44c8d9ae7d46b7a385361c69ac29dcab3ed761edce1c14ab8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"913ddbba170240070bd5921b8f33ea780021bdf42fbdfcd4fcb2691b1884ddde","impliedFormat":1},{"version":"b4e6d416466999ff40d3fe5ceb95f7a8bfb7ac2262580287ac1a8391e5362431","impliedFormat":1},{"version":"5fe23bd829e6be57d41929ac374ee9551ccc3c44cee893167b7b5b77be708014","impliedFormat":1},{"version":"0a626484617019fcfbfc3c1bc1f9e84e2913f1adb73692aa9075817404fb41a1","impliedFormat":1},{"version":"438c7513b1df91dcef49b13cd7a1c4720f91a36e88c1df731661608b7c055f10","impliedFormat":1},{"version":"cf185cc4a9a6d397f416dd28cca95c227b29f0f27b160060a95c0e5e36cda865","impliedFormat":1},{"version":"0086f3e4ad898fd7ca56bb223098acfacf3fa065595182aaf0f6c4a6a95e6fbd","impliedFormat":1},{"version":"efaa078e392f9abda3ee8ade3f3762ab77f9c50b184e6883063a911742a4c96a","impliedFormat":1},{"version":"54a8bb487e1dc04591a280e7a673cdfb272c83f61e28d8a64cf1ac2e63c35c51","impliedFormat":1},{"version":"021a9498000497497fd693dd315325484c58a71b5929e2bbb91f419b04b24cea","impliedFormat":1},{"version":"9385cdc09850950bc9b59cca445a3ceb6fcca32b54e7b626e746912e489e535e","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"84124384abae2f6f66b7fbfc03862d0c2c0b71b826f7dbf42c8085d31f1d3f95","impliedFormat":1},{"version":"63a8e96f65a22604eae82737e409d1536e69a467bb738bec505f4f97cce9d878","impliedFormat":1},{"version":"3fd78152a7031315478f159c6a5872c712ece6f01212c78ea82aef21cb0726e2","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"58b49e5c1def740360b5ae22ae2405cfac295fee74abd88d74ac4ea42502dc03","impliedFormat":1},{"version":"512fc15cca3a35b8dbbf6e23fe9d07e6f87ad03c895acffd3087ce09f352aad0","impliedFormat":1},{"version":"9a0946d15a005832e432ea0cd4da71b57797efb25b755cc07f32274296d62355","impliedFormat":1},{"version":"a52ff6c0a149e9f370372fc3c715d7f2beee1f3bab7980e271a7ab7d313ec677","impliedFormat":1},{"version":"fd933f824347f9edd919618a76cdb6a0c0085c538115d9a287fa0c7f59957ab3","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"6a1aa3e55bdc50503956c5cd09ae4cd72e3072692d742816f65c66ca14f4dfdd","impliedFormat":1},{"version":"ab75cfd9c4f93ffd601f7ca1753d6a9d953bbedfbd7a5b3f0436ac8a1de60dfa","impliedFormat":1},{"version":"f95180f03d827525ca4f990f49e17ec67198c316dd000afbe564655141f725cd","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"1364f64d2fb03bbb514edc42224abd576c064f89be6a990136774ecdd881a1da","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"950fb67a59be4c2dbe69a5786292e60a5cb0e8612e0e223537784c731af55db1","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"07ca44e8d8288e69afdec7a31fa408ce6ab90d4f3d620006701d5544646da6aa","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"4e4475fba4ed93a72f167b061cd94a2e171b82695c56de9899275e880e06ba41","impliedFormat":1},{"version":"97c5f5d580ab2e4decd0a3135204050f9b97cd7908c5a8fbc041eadede79b2fa","impliedFormat":1},{"version":"c99a3a5f2215d5b9d735aa04cec6e61ed079d8c0263248e298ffe4604d4d0624","impliedFormat":1},{"version":"49b2375c586882c3ac7f57eba86680ff9742a8d8cb2fe25fe54d1b9673690d41","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"847e160d709c74cc714fbe1f99c41d3425b74cd47b1be133df1623cd87014089","impliedFormat":1},{"version":"9fee04f1e1afa50524862289b9f0b0fdc3735b80e2a0d684cec3b9ff3d94cecc","impliedFormat":1},{"version":"5cdc27fbc5c166fc5c763a30ac21cbac9859dc5ba795d3230db6d4e52a1965bb","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"f416c9c3eee9d47ff49132c34f96b9180e50485d435d5748f0e8b72521d28d2e","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"14e5cdec6f8ae82dfd0694e64903a0a54abdfe37e1d966de3d4128362acbf35f","impliedFormat":1},{"version":"bbc183d2d69f4b59fd4dd8799ffdf4eb91173d1c4ad71cce91a3811c021bf80c","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"8dbc4134a4b3623fc476be5f36de35c40f2768e2e3d9ed437e0d5f1c4cd850f6","impliedFormat":1},{"version":"4e06330a84dec7287f7ebdd64978f41a9f70a668d3b5edc69d5d4a50b9b376bb","impliedFormat":1},{"version":"65bfa72967fbe9fc33353e1ac03f0480aa2e2ea346d61ff3ea997dfd850f641a","impliedFormat":1},{"version":"c06f0bb92d1a1a5a6c6e4b5389a5664d96d09c31673296cb7da5fe945d54d786","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"872caaa31423f4345983d643e4649fb30f548e9883a334d6d1c5fff68ede22d4","impliedFormat":1},{"version":"94404c4a878fe291e7578a2a80264c6f18e9f1933fbb57e48f0eb368672e389c","impliedFormat":1},{"version":"5c1b7f03aa88be854bc15810bfd5bd5a1943c5a7620e1c53eddd2a013996343e","impliedFormat":1},{"version":"09dfc64fcd6a2785867f2368419859a6cc5a8d4e73cbe2538f205b1642eb0f51","impliedFormat":1},{"version":"bcf6f0a323653e72199105a9316d91463ad4744c546d1271310818b8cef7c608","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"351475f9c874c62f9b45b1f0dc7e2704e80dfd5f1af83a3a9f841f9dfe5b2912","impliedFormat":1},{"version":"ac457ad39e531b7649e7b40ee5847606eac64e236efd76c5d12db95bf4eacd17","impliedFormat":1},{"version":"187a6fdbdecb972510b7555f3caacb44b58415da8d5825d03a583c4b73fde4cf","impliedFormat":1},{"version":"d4c3250105a612202289b3a266bb7e323db144f6b9414f9dea85c531c098b811","impliedFormat":1},{"version":"95b444b8c311f2084f0fb51c616163f950fb2e35f4eaa07878f313a2d36c98a4","impliedFormat":1},{"version":"741067675daa6d4334a2dc80a4452ca3850e89d5852e330db7cb2b5f867173b1","impliedFormat":1},{"version":"f8acecec1114f11690956e007d920044799aefeb3cece9e7f4b1f8a1d542b2c9","impliedFormat":1},{"version":"178071ccd043967a58c5d1a032db0ddf9bd139e7920766b537d9783e88eb615e","impliedFormat":1},{"version":"3a17f09634c50cce884721f54fd9e7b98e03ac505889c560876291fcf8a09e90","impliedFormat":1},{"version":"32531dfbb0cdc4525296648f53b2b5c39b64282791e2a8c765712e49e6461046","impliedFormat":1},{"version":"0ce1b2237c1c3df49748d61568160d780d7b26693bd9feb3acb0744a152cd86d","impliedFormat":1},{"version":"e489985388e2c71d3542612685b4a7db326922b57ac880f299da7026a4e8a117","impliedFormat":1},{"version":"5cad4158616d7793296dd41e22e1257440910ea8d01c7b75045d4dfb20c5a41a","impliedFormat":1},{"version":"04d3aad777b6af5bd000bfc409907a159fe77e190b9d368da4ba649cdc28d39e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74efc1d6523bd57eb159c18d805db4ead810626bc5bc7002a2c7f483044b2e0f","impliedFormat":1},{"version":"19252079538942a69be1645e153f7dbbc1ef56b4f983c633bf31fe26aeac32cd","impliedFormat":1},{"version":"bc11f3ac00ac060462597add171220aed628c393f2782ac75dd29ff1e0db871c","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"3b0b1d352b8d2e47f1c4df4fb0678702aee071155b12ef0185fce9eb4fa4af1e","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"a344403e7a7384e0e7093942533d309194ad0a53eca2a3100c0b0ab4d3932773","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb18bf4a61a17b4a6199eb3938ecfa4a59eb7c40843ad4a82b975ab6f7e3d925","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"e9b6fc05f536dfddcdc65dbcf04e09391b1c968ab967382e48924f5cb90d88e1","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"3cd8f0464e0939b47bfccbb9bb474a6d87d57210e304029cd8eb59c63a81935d","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"3026abd48e5e312f2328629ede6e0f770d21c3cd32cee705c450e589d015ee09","impliedFormat":1},{"version":"8b140b398a6afbd17cc97c38aea5274b2f7f39b1ae5b62952cfe65bf493e3e75","impliedFormat":1},{"version":"7663d2c19ce5ef8288c790edba3d45af54e58c84f1b37b1249f6d49d962f3d91","impliedFormat":1},{"version":"5cce3b975cdb72b57ae7de745b3c5de5790781ee88bcb41ba142f07c0fa02e97","impliedFormat":1},{"version":"00bd6ebe607246b45296aa2b805bd6a58c859acecda154bfa91f5334d7c175c6","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"0d28b974a7605c4eda20c943b3fa9ae16cb452c1666fc9b8c341b879992c7612","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"87ac2fb61e629e777f4d161dff534c2023ee15afd9cb3b1589b9b1f014e75c58","impliedFormat":1},{"version":"13c8b4348db91e2f7d694adc17e7438e6776bc506d5c8f5de9ad9989707fa3fe","impliedFormat":1},{"version":"3c1051617aa50b38e9efaabce25e10a5dd9b1f42e372ef0e8a674076a68742ed","impliedFormat":1},{"version":"07a3e20cdcb0f1182f452c0410606711fbea922ca76929a41aacb01104bc0d27","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"4cd4b6b1279e9d744a3825cbd7757bbefe7f0708f3f1069179ad535f19e8ed2c","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"c0eeaaa67c85c3bb6c52b629ebbfd3b2292dc67e8c0ffda2fc6cd2f78dc471e6","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"d23df9ff06ae8bf1dcb7cc933e97ae7da418ac77749fecee758bb43a8d69f840","affectsGlobalScope":true,"impliedFormat":1},{"version":"040c71dde2c406f869ad2f41e8d4ce579cc60c8dbe5aa0dd8962ac943b846572","affectsGlobalScope":true,"impliedFormat":1},{"version":"3586f5ea3cc27083a17bd5c9059ede9421d587286d5a47f4341a4c2d00e4fa91","impliedFormat":1},{"version":"a6df929821e62f4719551f7955b9f42c0cd53c1370aec2dd322e24196a7dfe33","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"e462a655754db9df18b4a657454a7b6a88717ffded4e89403b2b3a47c6603fc3",{"version":"8f6e4a982867301da142fa213f19d4f6d925d6097258098d42ca3741692a5c2a","signature":"3419a22e3e835879e8da575378800545e92e8510acc1792a4a418dca8a4a35bc"},{"version":"50b2d59f7e54f367f717798fcd24e3f9e75a9cdd9398ad81f5525ec3471ae527","signature":"9cd1e145ecc8c4152f93e4dfbbe530b0d0cd92b2cbdaaf63757b36b638373d5d"},{"version":"565c1a92290bf39be76637ed3d11b3f2538ed016fcefd0cc215c575120ab00c0","signature":"8b03e614b4cd841c6abc286de6ce6e1b214ef8acfd3a83a1c682f4d22690be88"},{"version":"0d892859e72968b1c5467a19f845f2d3f59817e69952f7e286ea67bb86aedd8f","signature":"68dee8f2421c4fb8c512c665814649778d8b7a65427c0c6058e803017084eb35"},{"version":"c57b441e0c0a9cbdfa7d850dae1f8a387d6f81cbffbc3cd0465d530084c2417d","impliedFormat":99},{"version":"26c57c9f839e6d2048d6c25e81f805ba0ca32a28fd4d824399fd5456c9b0575b","impliedFormat":1},{"version":"339d9e082c4c255ac7573a8903d667d3c742affe2c8af479d776e3a47430cd80","signature":"400b40fe5d5f4140993b0ac871686d2b7611ab791e8810b2e14f2d89701fc49e"},{"version":"191e4e6aceae3602aa3a1e9a6bc0e98821d6d5fb787e2bc16e250439482bddb6","impliedFormat":1},{"version":"4f0654e96de0843fd5b17133bde3f324d259828ecd852c326d58e66f0d3da616","signature":"081a855326ae9420dcd04b8b4a6a350d819447986540aeecca33ce50e4a0202f"},{"version":"23b006c1c80b4eae319c6fe48768073be2b4a646824df567b42d85a3a8ff3534","signature":"27577f34f4c7766dfd18d828ed1fff44e2ca045903e9bdd7a66d3448ea66f196"},{"version":"33aa468810764d1d72211c34635ed949a9086eb6c7e999921c601081510607e9","signature":"8f8a40c4e63a75fb54529b2f70173f6bdf4f9dcecfa4a559423c368639034114"},{"version":"9429d5feafac99952ac90f0fb7148264847b5d914ed2790d953f0ddcc0e762ad","signature":"f17af92d68e4dd210774399f6f0f83a917429cf3402ce4715e8ed0f4fe2ddc8f"},{"version":"fbc6095032e74690254e6591fad71b2a424cc063d21af3160879bb3a92d8beac","impliedFormat":99},{"version":"2fbe402f0ee5aa8ab55367f88030f79d46211c0a0f342becaa9f648bf8534e9d","impliedFormat":1},{"version":"b94258ef37e67474ac5522e9c519489a55dcb3d4a8f645e335fc68ea2215fe88","impliedFormat":1},{"version":"f658196a843a2d2586e31f6419ccbe6148a21d8b24e7acff21f500a704a1ca9a","signature":"f28686ca1340b1b485fcf38f4da3aaea094d8ea74689f1d805211351370244ef"},{"version":"1a5c0f8ce3e62b7bcaaed871d23d4004eca041149fa555c1f13828799ae75480","signature":"c8b2df0c5f4eea8edfa5b00624832c62a758cfe7ba9dc555beb8789b3010eb98"},{"version":"a7bf1d055d338f994f8667fe843e51fbdcebbbabc2b5bfdff4e268e279df07e4","signature":"8345aa3f01a200786250ad751c91d6167924cfde18b12f210b2e5f8674f1e443"},{"version":"2f23e08b1ceaa06db146f282f2d185586900b9a6d39b5344b979e490b63d68c2","signature":"b401f77d75fa88c73c7a38887b40f72677bd96879ca4ad3ec3c43a51f24444c5"},{"version":"e18a13856e22d5eea048b9457f5b4b2a4562072c3dbde5aad7bfddfba4056bc7","signature":"cd6424d084d9cbe42f6f683d9441e1ab4bc391378353659b4a520b10e20c029d"},{"version":"de4834633cfaefadff387db69c8bb7f3ebda5372c89c49de8b548dc2a658785c","signature":"eb36aefc0be411804db7fbb957fa412211b874b0a02d28a97c8555b71374a858"},{"version":"fe93c474ab38ac02e30e3af073412b4f92b740152cf3a751fdaee8cbea982341","impliedFormat":1},{"version":"aa4feed67c9af19fa98fe02a12f424def3cdc41146fb87b8d8dab077ad9ceb3c","impliedFormat":1},{"version":"1e00b8bf9e3766c958218cd6144ffe08418286f89ff44ba5a2cc830c03dd22c7","impliedFormat":1},{"version":"671202b3d2e4c00e740305f7a320b3964f5645896e14550b1069c5254bce3267","impliedFormat":99},{"version":"246ad924fb690f3af3aaead6d056184f74cfd51749773fd20523f9ce389e5e20","impliedFormat":99},{"version":"251000fa47da0b0ea2758b44df7311c4e83fdcde2c67e3f6631bd9635832ba5c","impliedFormat":99},{"version":"8a66a185e78f615f2a8abfda16d7eb4b490a965f34efe611ae2186d9dd21361d","impliedFormat":99},{"version":"dcc5f1a498cdd4fa010772391ce3f71a609f5c50f249f6dda98a00e32bf8adbe","impliedFormat":99},{"version":"53b63b74cba2195039551edaeecd9e0083067e20ef6e1b95e3b0fda92493b44a","impliedFormat":99},{"version":"3d7c038bd6350184b6c093676573c13971489a14a9d5db7bb4c9a15c1fa2ba91","impliedFormat":99},{"version":"b19b6148f916f015348018f2f4eac4fbf28acbb1883665c9034ee8b8aaa4f7dd","impliedFormat":99},{"version":"f4f83d57a328688e00077e95ad50c0ed9d57747f54aa88aee7003f8c66e69661","impliedFormat":99},{"version":"0a9b96b5b16c3355f56d66068f6415811100456479071e0a227c0fe9fcb0d0c2","impliedFormat":99},{"version":"364c3283f107e59ef3f7a0bd93a94c9e6a97f562c8d9120cb3c3669ce19c2bfb","impliedFormat":99},{"version":"9a58286fc310d364f7ddf0781d906be0f0c3e314f4ba9925f8cad7f6b343635a","impliedFormat":99},{"version":"51fe876dd04e220fad80f64edfef75730308ebc0f6e0e3d3899c976f97a548ee","impliedFormat":99},{"version":"b77e0039b0eb170bbf64adbd1b6d7caceed08f2e95abec56a3c7f789a5b94702","impliedFormat":99},{"version":"ee641e3f9520cc445e653f033fc76a9d3bfb26cac8929b4ba4fbc2e40eeed65a","impliedFormat":99},{"version":"1c6ff7168bdc65e80cd3308b448037e0756c2049c1b4f7fee9b906db2b4eb4cc","impliedFormat":99},{"version":"2703ba30a3330f2dd4866c57f5ab4deaf79ffe5473fb53f6f9fdf3355ab80134","impliedFormat":99},{"version":"b457f37de4cd510a57b2c799b03b2e25ddc0d127a9cdd38d16929abd8b9ac079","impliedFormat":99},{"version":"361e5122779ff07d22373e6effa87335656043e74b2d23f65ef1ab95ce112f53","impliedFormat":99},{"version":"d71557daa064d0509f8a03c76b0e96c99c40222ec7a4010acd8d0157c078caa7","impliedFormat":99},{"version":"f83dc87bb5ad87bbc13eaf38d1122a4b6ea08f9df7cf977551a207cdd1d9c9ad","impliedFormat":99},{"version":"7f9c87ef7acce4bc3e8743922e457402555f8e77d7a585b46fd06cb50534df1d","impliedFormat":99},{"version":"f5758605565692fa08ac8176c8395a733cd885ffd77a2cdc7ea89c06a1b13a73","impliedFormat":99},{"version":"1daf419444f812ffe29154b06b9846b81a509890dd5d4a01c12f592152e6736b","impliedFormat":99},{"version":"07a2dcd4b438a7d389253cf0276c65b6484ff1802af3bbae64da89840c6d1e60","impliedFormat":99},{"version":"f8d073c81afe390c2c66391d8e0d3ecc4769dd1678c44143949f6f61671e5d00","impliedFormat":99},{"version":"c94a0b2162979ff2cda3e1706a0eac4a7e13b1dbeb138b6a331c0ac8cded80e7","impliedFormat":99},{"version":"efafda8ab711a6b463c815357d008953816db3964bb68f1972964563a013ade6","impliedFormat":99},{"version":"b7f3d3b0248617ce229e02b5f5ed640ffa59dc33cfbf0607127254ecad40b214","impliedFormat":99},{"version":"44f18a86f2695f62e207ea094b75d2ea060931cf467415c246bedbe404c99976","impliedFormat":99},{"version":"56c69663e1246d1b57374436efd8ee5d4f8894466e53f25931eb05b108f00f1f","impliedFormat":99},{"version":"333a8d500cfc9ba377c7ea02a250bcf2f9cbc56bdcbb9fd9b0402e89807d085f","impliedFormat":99},"5ad76279d0a947acc3431599b0354646dd56e788c64cdfcf17383101b3ec3019",{"version":"ebab4440936c681f20faf18aa0fa2b2bd4ddc81399e142cd499b640600392e46","signature":"3ef1f2be3b347b6cec1f4332e296744f3b66a7db5e6d81db290a55fb90b6ce79"},{"version":"e2cc598bd90b0030717deb36971854c2d66a867c6d8242315bc91d2a77320579","signature":"2ebc90be29c541cbe65fc76ab9642c7348e8f76f04a670930d705b67cc0df910"},{"version":"faa529629378235710aa9a405fba4aca6a7600dba334f553ecdd28dda432ce5d","signature":"c424328f5d2bfec56decc356345ac69823634a2e75bb9ef259abfeb51a54b2c9"},{"version":"a90a5d2535f1de61b824dc8bf1928f68b20df3e323ba924f682d5c1f18b88d4d","impliedFormat":1},"0da037d173d79f1a513f6b6bbadc3c9977d9864a04cb9914965ecc5a6d089ee3",{"version":"64e4057dd49916d73d3c577e0751a6c8f22c6152b295ea019161966051951a74","impliedFormat":99},{"version":"caf4af98bf464ad3e10c46cf7d340556f89197aab0f87f032c7b84eb8ddb24d9","impliedFormat":99},{"version":"0a60daa545b41003e922202c01e47d0cea514b09ba0af1e4684222f5b332841e","impliedFormat":99},{"version":"85142ec7bf3ef1cbfd7c004092c64abe9eb0a4ed9325604ad14a922591c37025","impliedFormat":99},{"version":"8bfb5c47f3e98050a3cb2e0e076bd43c5c0c5da188478316e5f70c4474638366","signature":"77e18e7903fb6d33e1581226fc4caf575f03b827e5f312e7e9fe78af3754aa6c"},{"version":"14ecfc29e0c44ad4c5e50f9b597492cd8f45a2a635db8b5fe911a5da83e26cf8","impliedFormat":1},{"version":"569e762cf47aafdad508360a443c6c757e56c61db3b652b65458a7d168d139c4","impliedFormat":99},{"version":"02ed2766d79a00719ac3cc77851d54bd7197c1b12085ea12126bc2a65068223e","impliedFormat":99},{"version":"4b84373e192b7e0f8569b65eb16857098a6ee279b75d49223db2a751fdd7efde","impliedFormat":99},{"version":"5aeea312cd1d3cc5d72fc8a9c964439d771bdf41d9cce46667471b896b997473","impliedFormat":99},{"version":"e7f5439ffafa3e6de800aa694ccc0eca42c1cc3f36adc5d2a4cb03ee7cf8858f","impliedFormat":99},{"version":"3214d09fc24e35d90203d6360cdcb3f012762992e051690dae154bf3bfa3a2b7","impliedFormat":99},{"version":"bb1a4927cfa70ea678d5fb8f879da131e33a48de0dbe8f400df75935da30a15b","impliedFormat":99},{"version":"5c59fd485fff665a639e97e9691a7169f069e24b42ffc1f70442c55720ad3969","impliedFormat":99},{"version":"c4342058b50422e5522fda416980f922be817a72c58132b313510bd5e9e71acf","impliedFormat":99},{"version":"df283af30056ef4ab9cf31350d4b40c0ed15b1032833e32dc974ade50c13f621","impliedFormat":99},{"version":"9de40cf702d52a49d6f3d36d054fc12638348ea3e1fb5f8d53ef8910e7eaa56f","impliedFormat":99},{"version":"2f844dc2e5d3e8d15a951ff3dc39c7900736d8b2be67cc21831b50e5faaa760a","impliedFormat":99},{"version":"ecbbfd67f08f18500f2faaaa5d257d5a81421e5c0d41fa497061d2870b2e39db","impliedFormat":99},{"version":"79570f4dfd82e9ae41401b22922965da128512d31790050f0eaf8bbdb7be9465","impliedFormat":99},{"version":"4b7716182d0d0349a953d1ff31ab535274c63cbb556e88d888caeb5c5602bc65","impliedFormat":99},{"version":"d51809d133c78da34a13a1b4267e29afb0d979f50acbeb4321e10d74380beeea","impliedFormat":99},{"version":"e1dafdb1db7e8b597fc0dbc9e4ea002c39b3c471be1c4439eda14cf0550afe92","impliedFormat":99},{"version":"f4c4d85e379ef56a6b07572429ea2ff7eeb7d4770fce6e0b08fcbd0a8757bf10","impliedFormat":99},{"version":"ba0badf6398010ef0ba77947d4b52078fc84cb4f209f6b70194b3b2cfb1823d8","impliedFormat":99},{"version":"d342d8a9aca5fe4eca8f7396a81298a80875f9668eb8bc721ad43695cb671d71","impliedFormat":99},{"version":"7bc778b8bee83db0c49cfd0721810436f561bd1f6aee74da4e919dbb0523a3d1","impliedFormat":99},{"version":"6429bd3f4344763f33df67b3eba311eb1dab6c5b73702db8150a14ac8e50219b","impliedFormat":99},{"version":"c61faf029182aa6eaf03cc24d47c280acec90b0a0bf376441cf3e0672433b4af","signature":"72c9cb520b46bd8f054305abb07fde45cf08eec0418bc897fbd01f4256346000"},{"version":"9b71d515484498527ba8475ec993868c09b8ccd339facbab222937310ccdf24f","signature":"7fced774d307155dbe328583316013de87b8d033941c7c306909e3dba9a401d1"},{"version":"4d4ddd36b11fec7ac07b52803e633934e229b4a1dd14cdc738c2fe30a2c84347","signature":"7ea8fbbd49fa70c5bc5754a515fcaa74cc573295dda1268450f552bae06d8c50"},{"version":"77ca5266064ef67a2e3200a445de86a671d2d58a5896a7ec49838274eb212ceb","signature":"3ca08d1de6617f194f145893194747b23f1d725d16992305ed17aa972b7a4231"},{"version":"29adeb7739fa900bbfd3e063c9f9aa1eaf9f499a8bdddb3dbabbaf9c12c7dbd4","signature":"f09c76a42785df3ff75ae03049307e35c3e1296bde8db03d32545ca1cfbebaeb"},{"version":"8323b9c1be8bcc3292f5b238f061ec2d6e5052ae393c0b6ea02a39ef4325f314","signature":"2b1fc663bdd7ef47680091cc43616d65564e7677469003e36cfc7fe33a3e0870"},{"version":"580f43a7be96156838c301efd494acade05cf6444591fa3ff0f2b0d34bffc7e9","signature":"9130f635fecab3bc968482c7b43671d6b56204b8099fc276fd4cace26e56dddb"},{"version":"915965d7fc3a2476f5844fce7a50d4d2b981741082b8050e48d9d7f50e38d490","signature":"6c45675928d5db83b67f6f8d0c2238049d5290a975c3d1c99b7d2cdf8b415168"},{"version":"097c250129fc05fe863b4a465d18801e737c54ef12737c6cf975abc79d9d5812","signature":"f5374e9235801cafb2b901e16acb885b4a68e9710e0ab8b185fcc4af1b1085c4"},{"version":"43dfc34406c7eab26954e7ae86126152fecc86a031f441d553a1d66c5c70a762","signature":"2326399a733436d3eb90fa1e15818660067f4b3c52e7788c8d86e825f70258e7"},{"version":"d9467ba0e3d6cbb4f21b72dd8f881cb07760d5806bc00bb8be4a1bbc5ad236be","signature":"616aa4011d781d844f441c0ab48e77368c6aac04955e63ce219579e61ba985c3"},{"version":"2e9d8fa45af8a18357b1c165a4e876008bf038a418469db931461ec9f7b130a0","signature":"f97b600fd75777c5e58ccda9f3eb4129ea091f5214442dfe037820aaa4bab9ba"},{"version":"bbde0f159288f4598ffbd55de66d39c0ad3a922b18e4d880239e3b047628c2f6","signature":"92654f37bf96e325912e8792f81e611dce2ec0bab1606cd45e2d1b8bdb94708c"},{"version":"8dd450de6d756cee0761f277c6dc58b0b5a66b8c274b980949318b8cad26d712","impliedFormat":99},{"version":"904d6ad970b6bd825449480488a73d9b98432357ab38cf8d31ffd651ae376ff5","impliedFormat":99},{"version":"2059b04988eece7f6e992a5c218e9c5361b8fed24cb04caddb7e1cb4339e236b","impliedFormat":99},{"version":"da55d83c1e799e52a50c2398def6ec2febdab9a74c919a82242faf3de907eda5","signature":"4c79714c1e88b8b2acf634bfd51c307598a7bd578361ba0218cd4ba6b2c3a4a1"},"6d5718135a75a19fd5e0f3689d63ac36431c1db1dfe53187eb7020af351ff540",{"version":"244f09ba3767cb9b2717feb29d7dd0b41dffd8dd9396db78345866ef67f8d234","signature":"3a8523bfa20e149df671c3166d3c2e0c8e5b9f015b121a6855deedf209096b5e"},{"version":"98a3b313e1555befc3b988bf0cabaad1a6230bc5be28ee0a5355f602a354ae17","signature":"d27a9356be69b5c023f5284d747415f8716cfea95d3c7da1736c2a8312327027"},{"version":"71acd198e19fa38447a3cbc5c33f2f5a719d933fccf314aaff0e8b0593271324","impliedFormat":99},{"version":"2524ec7d048ec1cbbd6208aeff79c4c593472807c35bd0fd4871ca1fedd0a6b3","signature":"638e231c7398bc8eae56d65bed1484e1d4b0126fdfa21930a51e3c18dace3712"},{"version":"68b6a7501a56babd7bcd840e0d638ee7ec582f1e70b3c36ebf32e5e5836913c8","impliedFormat":99},{"version":"0fa6e03fb138801f6af97b485127a9717867061d4d3016bf0db21bf7b32f5d5a","impliedFormat":99},{"version":"7b0f856869194f2e7783b5bfa71c805358d2de31d743750f16217719ac2ab40b","signature":"364ff75f14bbc7252e5c5c306c34ff281eef83c6fd43b0e5fda5b119ee929486"},{"version":"7647f5fac37c78c824f91e1a9a595f73ebf6600690e9d2c3822ae311abc4e6ba","signature":"1a5acd03815d5b083b687112583a472483d184a0cb7d67f32c43731bcdb62b9c"},"d1bfe6dc2efd812a4b54890c6454399a702cf52a78b3192fb0c1be5dc6a583ad",{"version":"6b5f886fe41e2e767168e491fe6048398ed6439d44e006d9f51cc31265f08978","impliedFormat":99},{"version":"56a87e37f91f5625eb7d5f8394904f3f1e2a90fb08f347161dc94f1ae586bdd0","impliedFormat":99},{"version":"f42faa092a65655c60ad6f92215e33a68926d231f8dbcd9aca378c10b9ce0f26","impliedFormat":99},{"version":"a0fa43b27364a340ea7639ff5fdffbcd375ecf49e6ed5df3eec73b3d74a84d58","impliedFormat":99},{"version":"0d00499220a62bfadef0307ae41537f2e801fd27e1324e5e03e4812e15725fe0","signature":"8aaf767819bbc2851c0d316381c657fee94f0e90830f99ad24f93bcaa2c2e896"},{"version":"990d03fe9c59d24057c1f3aa5ae4b29a1115853adf8f89a4e4055fb3f7a7f190","signature":"7837dd9c018c571283ac6e26b11fd830ca92edacdfde40f0dbf8ad4e9643b736"},{"version":"de4f1363818282123597b0ded73fecde4bede56806bfcba3f1f7cef1aabe1609","signature":"45b373ad2e114de335dd3eaf62f9658266d71c2f34537489f88f3b4815fa72f8"},{"version":"be49c57a31ed4a44c9f1eb8b8c10826a4dd5693e303f91f4dc6c39cf4bbbe84b","signature":"c02404d3f6e367821015ba682be0e728f23deddd5c7f3028a14d98be145ddbbf"},{"version":"79bd4a7129b517a39490071fbb2e99bcd038f96df6322e625cb7aa374a94f17b","signature":"e5842a5eb3f2dab2e5834eb3c1b207d4e907b7d8c90ddd9c285e3eaed8a33c83"},{"version":"d370d544e2f5b253ffe09d0c80a7c9281ea10789a26efd15ab69e5efa110ff40","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},"e0902e08411183d05f6b984fe28e4a00c6df02532ef8ee8ee72c91e6ba897484","e783f37fc1b553d3bc0a1a71d1ff74aa596b321f93e86b968083e5b23239c29a","a30d71fd36c3b108ac7232a17f74125a5b449e2df32318a2340d031520d5390a",{"version":"c83d44ecf35c1ca4cbd081bc488221a58692c5ce12d55cd8baaaad43fc9d527b","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},"30608cdcc5e352a0bfc49b39dd099c05488c636506039001590f5ce6f7bcb412",{"version":"e98ef31631c8f0792c91db8a1153dd57e10786b98163f0d7691808adb03b2982","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"a1e3be755cce01c7990d56199786aa6c80538a283586dbf633d37b9f75761a43","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"}],"root":[[411,415],418,[420,423],[427,432],[468,471],473,478,[502,514],[518,521],523,[526,528],[533,545]],"options":{"allowJs":true,"declaration":true,"declarationMap":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":4},"referencedMap":[[540,1],[539,2],[541,3],[542,4],[543,5],[544,6],[545,7],[538,8],[411,9],[514,10],[503,11],[509,12],[519,13],[520,14],[528,15],[534,16],[431,17],[432,18],[469,19],[535,20],[412,21],[470,22],[471,23],[468,24],[414,25],[415,25],[508,26],[536,27],[537,28],[512,29],[513,30],[527,31],[473,32],[506,33],[504,34],[511,26],[429,35],[507,36],[427,37],[428,38],[518,39],[521,38],[523,40],[526,41],[533,42],[505,38],[510,38],[478,43],[423,18],[418,44],[420,45],[421,18],[430,34],[502,46],[413,18],[422,47],[364,18],[529,48],[474,34],[517,49],[476,48],[515,48],[522,48],[531,50],[516,48],[475,34],[525,51],[524,52],[532,53],[424,34],[477,54],[530,18],[479,18],[436,18],[438,55],[440,56],[447,57],[441,58],[442,18],[443,55],[444,58],[439,18],[446,59],[437,18],[445,18],[448,60],[467,61],[461,62],[449,34],[465,62],[450,60],[451,63],[454,62],[452,64],[466,65],[462,64],[464,62],[463,64],[460,62],[459,62],[453,62],[455,66],[457,62],[458,62],[456,62],[143,67],[144,67],[145,68],[99,69],[146,70],[147,71],[148,72],[94,18],[97,73],[95,18],[96,18],[149,74],[150,75],[151,76],[152,77],[153,78],[154,79],[155,79],[156,80],[157,81],[158,82],[159,83],[100,18],[98,18],[160,84],[161,85],[162,86],[194,87],[163,88],[164,89],[165,90],[166,91],[167,92],[168,93],[169,94],[170,95],[171,96],[172,97],[173,97],[174,98],[175,18],[176,99],[178,100],[177,101],[179,102],[180,103],[181,104],[182,105],[183,106],[184,107],[185,108],[186,109],[187,110],[188,111],[189,112],[190,113],[191,114],[101,18],[102,18],[103,18],[142,115],[192,116],[193,117],[86,18],[199,118],[200,119],[198,34],[196,120],[197,121],[84,18],[87,122],[287,34],[104,18],[426,123],[425,124],[416,18],[85,18],[495,18],[485,18],[497,125],[486,126],[484,127],[493,128],[496,129],[488,130],[489,131],[487,132],[490,133],[491,134],[492,133],[494,18],[480,18],[482,135],[481,135],[483,136],[472,34],[93,137],[367,138],[371,139],[373,140],[220,141],[234,142],[338,143],[266,18],[341,144],[302,145],[311,146],[339,147],[221,148],[265,18],[267,149],[340,150],[241,151],[222,152],[246,151],[235,151],[205,151],[293,153],[294,154],[210,18],[290,155],[295,63],[382,156],[288,63],[383,157],[272,18],[291,158],[395,159],[394,160],[297,63],[393,18],[391,18],[392,161],[292,34],[279,162],[280,163],[289,164],[306,165],[307,166],[296,167],[274,168],[275,169],[386,170],[389,171],[253,172],[252,173],[251,174],[398,34],[250,175],[226,18],[401,18],[434,176],[433,18],[404,18],[403,34],[405,177],[201,18],[332,18],[233,178],[203,179],[355,18],[356,18],[358,18],[361,180],[357,18],[359,181],[360,181],[219,18],[232,18],[366,182],[374,183],[378,184],[215,185],[282,186],[281,18],[273,168],[301,187],[299,188],[298,18],[300,18],[305,189],[277,190],[214,191],[239,192],[329,193],[206,194],[213,195],[202,143],[343,196],[353,197],[342,18],[352,198],[240,18],[224,199],[320,200],[319,18],[326,201],[328,202],[321,203],[325,204],[327,201],[324,203],[323,201],[322,203],[262,205],[247,205],[314,206],[248,206],[208,207],[207,18],[318,208],[317,209],[316,210],[315,211],[209,212],[286,213],[303,214],[285,215],[310,216],[312,217],[309,215],[242,212],[195,18],[330,218],[268,219],[304,18],[351,220],[271,221],[346,222],[212,18],[347,223],[349,224],[350,225],[333,18],[345,194],[244,226],[331,227],[354,228],[216,18],[218,18],[223,229],[313,230],[211,231],[217,18],[270,232],[269,233],[225,234],[278,235],[276,236],[227,237],[229,238],[402,18],[228,239],[230,240],[369,18],[368,18],[370,18],[400,18],[231,241],[284,34],[92,18],[308,242],[254,18],[264,243],[243,18],[376,34],[385,244],[261,34],[380,63],[260,245],[363,246],[259,244],[204,18],[387,247],[257,34],[258,34],[249,18],[263,18],[256,248],[255,249],[245,250],[238,167],[348,18],[237,251],[236,18],[372,18],[283,34],[365,252],[83,18],[91,253],[88,34],[89,18],[90,18],[344,254],[337,255],[336,18],[335,256],[334,18],[375,257],[377,258],[379,259],[435,260],[381,261],[384,262],[410,263],[388,263],[409,264],[390,265],[396,266],[397,267],[399,268],[406,269],[408,18],[407,270],[362,271],[501,272],[500,273],[499,274],[498,275],[417,18],[81,18],[82,18],[13,18],[14,18],[16,18],[15,18],[2,18],[17,18],[18,18],[19,18],[20,18],[21,18],[22,18],[23,18],[24,18],[3,18],[25,18],[26,18],[4,18],[27,18],[31,18],[28,18],[29,18],[30,18],[32,18],[33,18],[34,18],[5,18],[35,18],[36,18],[37,18],[38,18],[6,18],[42,18],[39,18],[40,18],[41,18],[43,18],[7,18],[44,18],[49,18],[50,18],[45,18],[46,18],[47,18],[48,18],[8,18],[54,18],[51,18],[52,18],[53,18],[55,18],[9,18],[56,18],[57,18],[58,18],[60,18],[59,18],[61,18],[62,18],[10,18],[63,18],[64,18],[65,18],[11,18],[66,18],[67,18],[68,18],[69,18],[70,18],[1,18],[71,18],[72,18],[12,18],[76,18],[74,18],[79,18],[78,18],[73,18],[77,18],[75,18],[80,18],[120,276],[130,277],[119,276],[140,278],[111,279],[110,280],[139,270],[133,281],[138,282],[113,283],[127,284],[112,285],[136,286],[108,287],[107,270],[137,288],[109,289],[114,290],[115,18],[118,290],[105,18],[141,291],[131,292],[122,293],[123,294],[125,295],[121,296],[124,297],[134,270],[116,298],[117,299],[126,300],[106,301],[129,292],[128,290],[132,18],[135,302],[419,18]],"affectedFilesPendingEmit":[[540,51],[539,51],[541,51],[542,51],[543,51],[544,51],[545,51],[538,51],[514,51],[503,51],[509,51],[519,51],[520,51],[528,51],[534,51],[431,51],[432,51],[469,51],[535,51],[412,51],[470,51],[471,51],[468,51],[414,51],[415,51],[508,51],[536,51],[537,51],[512,51],[513,51],[527,51],[473,51],[506,51],[504,51],[511,51],[429,51],[507,51],[427,51],[428,51],[518,51],[521,51],[523,51],[526,51],[533,51],[505,51],[510,51],[478,51],[423,51],[418,51],[420,51],[421,51],[430,51],[502,51],[413,51],[422,51]],"version":"5.9.3"} \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0152212..c49b4db 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -27,7 +27,6 @@ services: environment: DATABASE_URL: "postgresql://kooltap:${DB_PASSWORD:-changeme}@postgres:5432/kooltap" JWT_SECRET: "${JWT_SECRET}" - JWT_REFRESH_SECRET: "${JWT_REFRESH_SECRET}" PORT: 3001 CORS_ORIGIN: "${DASHBOARD_URL:-http://localhost:3000}" RESEND_API_KEY: "${RESEND_API_KEY:-}" diff --git a/docker-compose.yml b/docker-compose.yml index a418519..fb5db77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,24 @@ version: '3.8' services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: kooltap + POSTGRES_USER: kooltap + POSTGRES_PASSWORD: kooltap + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kooltap"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - kooltap-net + api: build: context: . @@ -9,13 +27,13 @@ services: ports: - "3001:3001" environment: - - DATABASE_URL=file:./dev.db + - DATABASE_URL=postgresql://kooltap:kooltap@postgres:5432/kooltap - JWT_SECRET=dev-jwt-secret-change-in-production - - JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production - PORT=3001 - CORS_ORIGIN=http://localhost:3000 - volumes: - - api-data:/app/prisma + depends_on: + postgres: + condition: service_healthy networks: - kooltap-net @@ -34,8 +52,8 @@ services: - kooltap-net volumes: - api-data: + pgdata: networks: kooltap-net: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index b72bb63..8b3ad95 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -24,17 +24,17 @@ **Rationale:** Provides security (tokens can be revoked) and aligns with OAuth2 best practices for refresh token management. -## 5. SQLite for Development +## 5. PostgreSQL Everywhere -**Decision:** SQLite is the default development database. +**Decision:** PostgreSQL is used in development, CI and production. -**Rationale:** Zero configuration, fast for local development, and Prisma abstracts most differences. PostgreSQL migration path is documented in production.md. +**Rationale:** One database means one set of migrations; the schema uses JSON columns and serializable transactions that SQLite doesn't support the same way. -## 6. No WebSockets +## 6. Invalidation-Only Live Updates -**Decision:** Dashboard uses polling (TanStack Query) instead of WebSockets. +**Decision:** The API pushes topic names (`tabs`, `commands`, ...) over Socket.IO; the dashboard refetches that data over the normal REST API. It falls back to polling while the socket is down. -**Rationale:** Simpler implementation, adequate for the expected update frequency (5-30 second intervals), and avoids WebSocket server infrastructure. +**Rationale:** Events never carry data, so a socket can't see anything its owner couldn't fetch, and the REST API stays the single source of truth. ## 7. In-Memory Rate Limiting diff --git a/docs/architecture.md b/docs/architecture.md index 23933cd..736fffa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ KoolTap is a modular monolith designed for remote tab control in venues. - REST API with versioned endpoints (`/api/v1`) - JWT authentication for dashboard users - Bearer token authentication for devices -- SQLite database via Prisma ORM +- PostgreSQL database via Prisma ORM - Scheduled command expiry job - In-memory rate limiting @@ -19,7 +19,8 @@ KoolTap is a modular monolith designed for remote tab control in venues. - TanStack Query for API data fetching - shadcn/ui component library - Tailwind CSS styling -- Secure HttpOnly cookie-based auth +- Tokens kept in cookies (see security.md) +- Socket.IO connection for live updates, polling as fallback ### Shared Package - TypeScript types and constants diff --git a/docs/local-development.md b/docs/local-development.md index f87970c..c3dc088 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -54,9 +54,8 @@ pnpm db:reset # Reset database and re-seed ## Environment Variables ### API (`apps/api/.env`) -- `DATABASE_URL` - SQLite path (default: `file:./dev.db`) +- `DATABASE_URL` - PostgreSQL connection URL (`docker compose up postgres` starts one) - `JWT_SECRET` - JWT signing secret -- `JWT_REFRESH_SECRET` - Refresh token secret - `PORT` - API port (default: 3001) - `CORS_ORIGIN` - Allowed CORS origin (default: http://localhost:3000) diff --git a/docs/production.md b/docs/production.md index 69e5d5a..a99ca64 100644 --- a/docs/production.md +++ b/docs/production.md @@ -12,9 +12,8 @@ Create `.env` in the project root: # Database DB_PASSWORD= -# JWT secrets — generate with: openssl rand -hex 64 +# JWT secret — generate with: openssl rand -hex 64 JWT_SECRET=<64-char-hex-string> -JWT_REFRESH_SECRET=<64-char-hex-string> # URLs — set your actual domain/port DASHBOARD_URL=https://dashboard.yourdomain.com @@ -29,16 +28,16 @@ API_PORT=3001 docker compose -f docker-compose.prod.yml up --build -d ``` -### 3. Seed the database (first time only) +### 3. Create the venue and admin (first time only) + +Migrations run automatically when the API container starts. ```bash -# Generate Prisma client and run migrations -docker compose -f docker-compose.prod.yml exec api npx prisma migrate deploy - -# Seed demo data (or create your own venue/users) -docker compose -f docker-compose.prod.yml exec api npx ts-node prisma/seed.ts +docker compose -f docker-compose.prod.yml exec api node prisma/setup.js "My Venue" admin@myvenue.com '' ``` +`setup.js` does nothing when a venue already exists. `--force` wipes ALL data and starts over. + ### 4. Set up a reverse proxy (nginx/Caddy) Example Caddyfile: @@ -70,22 +69,11 @@ GRANT ALL PRIVILEGES ON DATABASE kooltap TO kooltap; ```env DATABASE_URL="postgresql://kooltap:your-password@localhost:5432/kooltap" JWT_SECRET="" -JWT_REFRESH_SECRET="" PORT=3001 CORS_ORIGIN="https://dashboard.yourdomain.com" ``` -### 3. Update Prisma schema for PostgreSQL - -In `apps/api/prisma/schema.prisma`, change: -```prisma -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} -``` - -### 4. Build and run +### 3. Build and run ```bash cd apps/api @@ -98,19 +86,6 @@ node dist/main --- -## Migration Steps: SQLite → PostgreSQL - -1. Update `schema.prisma` to use `postgresql` provider -2. Update `DATABASE_URL` environment variable -3. Run `prisma migrate dev` to create initial PostgreSQL migration -4. Export SQLite data and import to PostgreSQL (using `prisma db pull` + manual script, or insert via seed) -5. Run `prisma migrate deploy` on production - -Key differences to handle: -- SQLite uses `Int` for booleans → use `Boolean` in PostgreSQL -- SQLite uses `String` for JSON → use `Json` type in PostgreSQL -- UUID generation is handled by Prisma on both - ## Security Checklist - [ ] JWT secrets are 64+ random hex characters diff --git a/docs/security.md b/docs/security.md index a46b916..fc97865 100644 --- a/docs/security.md +++ b/docs/security.md @@ -11,15 +11,16 @@ ### Client Authentication - Passwords hashed with bcrypt (10 rounds) -- JWT access tokens (24h expiration) -- Refresh tokens with rotation and revocation -- Tokens stored in HttpOnly cookies on dashboard +- JWT access tokens (15 minute expiration) +- Refresh tokens (30 days) with rotation and revocation; a refresh token works only once +- The dashboard keeps both tokens in `SameSite=Lax` cookies (`Secure` over HTTPS). They are readable by + the dashboard's JavaScript, so an XSS bug would expose them; the short access token lifetime limits the damage ## Authorization ### Role-Based Access -- `admin`: Full access including device management and revocation -- `viewer`: Read-only access to tabs, devices, and commands +- `admin`: Full access, including users, device pairing, planned customers and sending reminders +- `viewer` (shown as staff): views tabs and commands and can close tabs ### Venue Isolation - Every user and device is scoped to exactly one venue @@ -34,9 +35,9 @@ - Body size limits enforced ### Rate Limiting -- Device sync: 1 request per 30 seconds per device -- Command creation: 10 requests per minute per user +- Login and device registration: 10 requests per minute per client - General: 100 requests per minute per client +- Device sync and command acks are not rate limited (they are authenticated by the device token) ### Error Handling - Consistent error format never exposes internals diff --git a/packages/shared/src/constants/index.d.ts b/packages/shared/src/constants/index.d.ts deleted file mode 100644 index 0f5c335..0000000 --- a/packages/shared/src/constants/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -export declare const DEVICE_ONLINE_THRESHOLD_MS = 60000; -export declare const DEVICE_STALE_THRESHOLD_MS = 300000; -export declare const DEVICE_OFFLINE_THRESHOLD_MS = 600000; -export declare const COMMAND_EXPIRY_HOURS = 24; -export declare const TOKEN_EXPIRY_DAYS = 90; -export declare const SYNC_RATE_LIMIT_WINDOW_MS = 30000; -export declare const COMMAND_RATE_LIMIT_WINDOW_MS = 60000; -export declare const COMMAND_RATE_LIMIT_MAX = 10; -export declare const CURSOR_TIEBREAKER_LENGTH = 8; -export declare const TOKEN_PREFIX_LENGTH = 8; -export declare const TOKEN_BYTE_LENGTH = 48; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/shared/src/constants/index.d.ts.map b/packages/shared/src/constants/index.d.ts.map deleted file mode 100644 index 2c5318f..0000000 --- a/packages/shared/src/constants/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,0BAA0B,QAAS,CAAC;AACjD,eAAO,MAAM,yBAAyB,SAAU,CAAC;AACjD,eAAO,MAAM,2BAA2B,SAAU,CAAC;AACnD,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAChD,eAAO,MAAM,4BAA4B,QAAS,CAAC;AACnD,eAAO,MAAM,sBAAsB,KAAK,CAAC;AACzC,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAC1C,eAAO,MAAM,mBAAmB,IAAI,CAAC;AACrC,eAAO,MAAM,iBAAiB,KAAK,CAAC"} \ No newline at end of file diff --git a/packages/shared/src/constants/index.js b/packages/shared/src/constants/index.js deleted file mode 100644 index 41cf128..0000000 --- a/packages/shared/src/constants/index.js +++ /dev/null @@ -1,15 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.TOKEN_BYTE_LENGTH = exports.TOKEN_PREFIX_LENGTH = exports.CURSOR_TIEBREAKER_LENGTH = exports.COMMAND_RATE_LIMIT_MAX = exports.COMMAND_RATE_LIMIT_WINDOW_MS = exports.SYNC_RATE_LIMIT_WINDOW_MS = exports.TOKEN_EXPIRY_DAYS = exports.COMMAND_EXPIRY_HOURS = exports.DEVICE_OFFLINE_THRESHOLD_MS = exports.DEVICE_STALE_THRESHOLD_MS = exports.DEVICE_ONLINE_THRESHOLD_MS = void 0; -exports.DEVICE_ONLINE_THRESHOLD_MS = 60_000; // 1 minute -exports.DEVICE_STALE_THRESHOLD_MS = 300_000; // 5 minutes -exports.DEVICE_OFFLINE_THRESHOLD_MS = 600_000; // 10 minutes -exports.COMMAND_EXPIRY_HOURS = 24; -exports.TOKEN_EXPIRY_DAYS = 90; -exports.SYNC_RATE_LIMIT_WINDOW_MS = 30_000; // 30 seconds -exports.COMMAND_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute -exports.COMMAND_RATE_LIMIT_MAX = 10; -exports.CURSOR_TIEBREAKER_LENGTH = 8; -exports.TOKEN_PREFIX_LENGTH = 8; -exports.TOKEN_BYTE_LENGTH = 48; -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/shared/src/constants/index.js.map b/packages/shared/src/constants/index.js.map deleted file mode 100644 index 3944c0d..0000000 --- a/packages/shared/src/constants/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAa,QAAA,0BAA0B,GAAG,MAAM,CAAC,CAAC,WAAW;AAChD,QAAA,yBAAyB,GAAG,OAAO,CAAC,CAAC,YAAY;AACjD,QAAA,2BAA2B,GAAG,OAAO,CAAC,CAAC,aAAa;AACpD,QAAA,oBAAoB,GAAG,EAAE,CAAC;AAC1B,QAAA,iBAAiB,GAAG,EAAE,CAAC;AACvB,QAAA,yBAAyB,GAAG,MAAM,CAAC,CAAC,aAAa;AACjD,QAAA,4BAA4B,GAAG,MAAM,CAAC,CAAC,WAAW;AAClD,QAAA,sBAAsB,GAAG,EAAE,CAAC;AAC5B,QAAA,wBAAwB,GAAG,CAAC,CAAC;AAC7B,QAAA,mBAAmB,GAAG,CAAC,CAAC;AACxB,QAAA,iBAAiB,GAAG,EAAE,CAAC"} \ No newline at end of file diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index a807934..ef71443 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -1,11 +1,6 @@ export const DEVICE_ONLINE_THRESHOLD_MS = 60_000; // 1 minute -export const DEVICE_STALE_THRESHOLD_MS = 300_000; // 5 minutes export const DEVICE_OFFLINE_THRESHOLD_MS = 600_000; // 10 minutes export const COMMAND_EXPIRY_HOURS = 24; export const TOKEN_EXPIRY_DAYS = 90; -export const SYNC_RATE_LIMIT_WINDOW_MS = 30_000; // 30 seconds -export const COMMAND_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute -export const COMMAND_RATE_LIMIT_MAX = 10; -export const CURSOR_TIEBREAKER_LENGTH = 8; export const TOKEN_PREFIX_LENGTH = 8; -export const TOKEN_BYTE_LENGTH = 48; \ No newline at end of file +export const TOKEN_BYTE_LENGTH = 48; diff --git a/packages/shared/src/index.d.ts b/packages/shared/src/index.d.ts deleted file mode 100644 index 29bb132..0000000 --- a/packages/shared/src/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './types'; -export * from './constants'; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/shared/src/index.d.ts.map b/packages/shared/src/index.d.ts.map deleted file mode 100644 index f48e376..0000000 --- a/packages/shared/src/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,aAAa,CAAC"} \ No newline at end of file diff --git a/packages/shared/src/index.js b/packages/shared/src/index.js deleted file mode 100644 index 4512cc2..0000000 --- a/packages/shared/src/index.js +++ /dev/null @@ -1,19 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __exportStar = (this && this.__exportStar) || function(m, exports) { - for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); -}; -Object.defineProperty(exports, "__esModule", { value: true }); -__exportStar(require("./types"), exports); -__exportStar(require("./constants"), exports); -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/shared/src/index.js.map b/packages/shared/src/index.js.map deleted file mode 100644 index df6dd64..0000000 --- a/packages/shared/src/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAwB;AACxB,8CAA4B"} \ No newline at end of file diff --git a/packages/shared/src/types/index.d.ts b/packages/shared/src/types/index.d.ts deleted file mode 100644 index ce9c914..0000000 --- a/packages/shared/src/types/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type DeviceStatus = 'online' | 'recently_seen' | 'stale' | 'offline' | 'revoked'; -export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired'; -export type CommandType = 'close_tab'; -export type PaymentMethod = 'cash' | 'payconiq' | 'payment_pending'; -export type ClientUserRole = 'admin' | 'viewer'; -export type ActorType = 'client_user' | 'device' | 'system'; -export type AuditAction = 'command_created' | 'command_acknowledged' | 'command_expired' | 'device_registered' | 'device_revoked' | 'snapshot_conflict'; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/shared/src/types/index.d.ts.map b/packages/shared/src/types/index.d.ts.map deleted file mode 100644 index 53f0a40..0000000 --- a/packages/shared/src/types/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,eAAe,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;AAExF,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC;AAExF,MAAM,MAAM,WAAW,GAAG,WAAW,CAAC;AAEtC,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,UAAU,GAAG,iBAAiB,CAAC;AAEpE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEhD,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,MAAM,MAAM,WAAW,GACnB,iBAAiB,GACjB,sBAAsB,GACtB,iBAAiB,GACjB,mBAAmB,GACnB,gBAAgB,GAChB,mBAAmB,CAAC"} \ No newline at end of file diff --git a/packages/shared/src/types/index.js b/packages/shared/src/types/index.js deleted file mode 100644 index aa219d8..0000000 --- a/packages/shared/src/types/index.js +++ /dev/null @@ -1,3 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/shared/src/types/index.js.map b/packages/shared/src/types/index.js.map deleted file mode 100644 index 87e53b9..0000000 --- a/packages/shared/src/types/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43c725..730da10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@types/passport-jwt': specifier: ^4.0.0 version: 4.0.1 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 '@types/uuid': specifier: ^9.0.7 version: 9.0.8 @@ -1452,6 +1455,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -1506,6 +1512,9 @@ packages: '@types/luxon@3.4.2': resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -1556,6 +1565,12 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -5416,6 +5431,8 @@ snapshots: dependencies: '@types/node': 20.19.43 + '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.19': dependencies: '@types/node': 20.19.43 @@ -5493,6 +5510,8 @@ snapshots: '@types/luxon@3.4.2': {} + '@types/methods@1.1.4': {} + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} @@ -5552,6 +5571,18 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.19.43 + form-data: 4.0.6 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + '@types/uuid@9.0.8': {} '@types/validator@13.15.10': {} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index ebfcb4d..15b9d57 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,6 +1,7 @@ #!/bin/sh # KoolTap Production Deploy # Usage: ./scripts/deploy.sh "My Venue" admin@myvenue.com password +# The venue and admin are only created on the first deploy; later deploys keep all data. VENUE_NAME="${1:-${SETUP_VENUE_NAME}}" ADMIN_EMAIL="${2:-${SETUP_ADMIN_EMAIL}}" @@ -40,4 +41,4 @@ docker compose -f docker-compose.prod.yml exec api sh -c " echo "" echo "=== Done ===" echo " Dashboard: https://kooltap.com" -echo " Login: $ADMIN_EMAIL / $ADMIN_PASSWORD" \ No newline at end of file +echo " Login: $ADMIN_EMAIL" \ No newline at end of file diff --git a/scripts/setup.sh b/scripts/setup.sh index c82438d..1c80bc4 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,6 +1,7 @@ #!/bin/sh # KoolTap Production Setup -# Usage: ./scripts/setup.sh "My Venue" admin@myvenue.com mypassword +# Usage: ./scripts/setup.sh "My Venue" admin@myvenue.com mypassword [--force] +# Does nothing if the database already has a venue, unless --force is given (wipes ALL data). # # Or with env vars: # SETUP_VENUE_NAME="My Venue" SETUP_ADMIN_EMAIL="admin@myvenue.com" SETUP_ADMIN_PASSWORD="mypassword" ./scripts/setup.sh @@ -21,20 +22,20 @@ echo " Venue: $VENUE_NAME" echo " Admin: $ADMIN_EMAIL" echo "" -# Step 1: Apply database schema -echo "[1/3] Applying database schema..." -npx prisma db push --accept-data-loss 2>&1 || { echo "FAILED"; exit 1; } -echo " ✓ Schema applied" - -# Step 2: Generate Prisma client -echo "[2/3] Generating Prisma client..." +# Step 1: Generate Prisma client +echo "[1/3] Generating Prisma client..." npx prisma generate 2>&1 || { echo "FAILED"; exit 1; } echo " ✓ Client generated" +# Step 2: Apply database migrations +echo "[2/3] Applying database migrations..." +node prisma/deploy.js 2>&1 || { echo "FAILED"; exit 1; } +echo " ✓ Migrations applied" + # Step 3: Seed with your data echo "[3/3] Seeding database..." SETUP_VENUE_NAME="$VENUE_NAME" SETUP_ADMIN_EMAIL="$ADMIN_EMAIL" SETUP_ADMIN_PASSWORD="$ADMIN_PASSWORD" \ - node prisma/setup.js || { echo "FAILED"; exit 1; } + node prisma/setup.js "$4" || { echo "FAILED"; exit 1; } echo "" echo "Setup complete. Login at https://kooltap.com" \ No newline at end of file