feat: add realtime
This commit is contained in:
+12
-3
@@ -25,9 +25,11 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
|
"@nestjs/platform-socket.io": "^10.4.22",
|
||||||
"@nestjs/schedule": "^4.0.0",
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.3.0",
|
"@nestjs/swagger": "^7.3.0",
|
||||||
"@nestjs/throttler": "^5.1.0",
|
"@nestjs/throttler": "^5.1.0",
|
||||||
|
"@nestjs/websockets": "^10.4.22",
|
||||||
"@prisma/client": "^5.10.0",
|
"@prisma/client": "^5.10.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
"prisma": "^5.10.0",
|
"prisma": "^5.10.0",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
@@ -60,17 +63,23 @@
|
|||||||
"ts-loader": "^9.5.1"
|
"ts-loader": "^9.5.1"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@kooltap/shared$": "<rootDir>/../../packages/shared/src"
|
"^@kooltap/shared$": "<rootDir>/../../../packages/shared/src"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PlannedTab" ADD COLUMN "deliveredAt" TIMESTAMP(3);
|
||||||
@@ -152,6 +152,8 @@ model PlannedTab {
|
|||||||
email String?
|
email String?
|
||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
// Set once the terminal reports an open tab with this name; it is then no longer sent to devices
|
||||||
|
deliveredAt DateTime?
|
||||||
|
|
||||||
venue Venue @relation(fields: [venueId], references: [id])
|
venue Venue @relation(fields: [venueId], references: [id])
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AuditModule } from './audit/audit.module';
|
|||||||
import { JobsModule } from './jobs/jobs.module';
|
import { JobsModule } from './jobs/jobs.module';
|
||||||
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
|
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
|
||||||
import { RemindersModule } from './reminders/reminders.module';
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -19,6 +20,7 @@ import { HealthController } from './health.controller';
|
|||||||
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
RealtimeModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
VenuesModule,
|
VenuesModule,
|
||||||
DevicesModule,
|
DevicesModule,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as crypto from 'crypto';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
import { TOKEN_EXPIRY_DAYS, TOKEN_BYTE_LENGTH, TOKEN_PREFIX_LENGTH } from '@kooltap/shared';
|
import { TOKEN_EXPIRY_DAYS, TOKEN_BYTE_LENGTH, TOKEN_PREFIX_LENGTH } from '@kooltap/shared';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -13,6 +14,7 @@ export class AuthService {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
|
private realtime: RealtimeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string) {
|
||||||
@@ -172,6 +174,8 @@ export class AuthService {
|
|||||||
|
|
||||||
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
|
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
|
||||||
|
|
||||||
|
this.realtime.notify(venueId, 'device');
|
||||||
|
|
||||||
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
|
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +203,8 @@ export class AuthService {
|
|||||||
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
|
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.realtime.notify(venueId, 'users');
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
import { CreateCommandDto } from './dto/create-command.dto';
|
import { CreateCommandDto } from './dto/create-command.dto';
|
||||||
import { COMMAND_EXPIRY_HOURS, DEVICE_OFFLINE_THRESHOLD_MS } from '@kooltap/shared';
|
import { COMMAND_EXPIRY_HOURS, DEVICE_OFFLINE_THRESHOLD_MS } from '@kooltap/shared';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export class CommandsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
|
private realtime: RealtimeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(venueId: string, dto: CreateCommandDto, user: any) {
|
async create(venueId: string, dto: CreateCommandDto, user: any) {
|
||||||
@@ -130,6 +132,8 @@ export class CommandsService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.realtime.notify(venueId, 'commands');
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||||
import { DevicesService } from './devices.service';
|
import { DevicesService } from './devices.service';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
@@ -37,7 +37,10 @@ export class DevicesController {
|
|||||||
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
|
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authenticated by the device token; the till must never be rate-limited out by other
|
||||||
|
// traffic from the venue's shared IP
|
||||||
@Post(':deviceId/sync')
|
@Post(':deviceId/sync')
|
||||||
|
@SkipThrottle()
|
||||||
@UseGuards(DeviceAuthGuard)
|
@UseGuards(DeviceAuthGuard)
|
||||||
@ApiOperation({ summary: 'Sync device state with server' })
|
@ApiOperation({ summary: 'Sync device state with server' })
|
||||||
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
|
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
|
||||||
@@ -46,6 +49,7 @@ export class DevicesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':deviceId/commands/:commandId/ack')
|
@Post(':deviceId/commands/:commandId/ack')
|
||||||
|
@SkipThrottle()
|
||||||
@UseGuards(DeviceAuthGuard)
|
@UseGuards(DeviceAuthGuard)
|
||||||
@ApiOperation({ summary: 'Acknowledge a command result' })
|
@ApiOperation({ summary: 'Acknowledge a command result' })
|
||||||
@ApiResponse({ status: 200, description: 'Command acknowledged' })
|
@ApiResponse({ status: 200, description: 'Command acknowledged' })
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { DevicesService } from './devices.service';
|
||||||
|
|
||||||
|
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
|
||||||
|
|
||||||
|
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
|
||||||
|
const tx = {
|
||||||
|
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn() },
|
||||||
|
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn() },
|
||||||
|
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
|
||||||
|
device: { update: jest.fn() },
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
|
||||||
|
tabOwnership: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
$transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||||
|
remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) },
|
||||||
|
};
|
||||||
|
const audit = { log: jest.fn() };
|
||||||
|
const realtime = { notify: jest.fn() };
|
||||||
|
const service = new DevicesService(prisma as any, audit as any, realtime as any);
|
||||||
|
return { service, prisma, tx, realtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
|
||||||
|
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
|
||||||
|
|
||||||
|
describe('DevicesService.sync planned customers', () => {
|
||||||
|
it('marks planned customers delivered once the device reports a tab with their name', async () => {
|
||||||
|
const { service, tx } = createService();
|
||||||
|
|
||||||
|
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', 'Piet')] });
|
||||||
|
|
||||||
|
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { venueId: 'v1', deliveredAt: null, customerName: { in: ['Jan', 'Piet'] } },
|
||||||
|
data: { deliveredAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only sends customers that have not been delivered yet', async () => {
|
||||||
|
const { service, prisma } = createService();
|
||||||
|
|
||||||
|
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||||
|
|
||||||
|
expect(prisma.plannedTab.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { venueId: 'v1', deliveredAt: null } }),
|
||||||
|
);
|
||||||
|
expect(result.plannedTabNames).toEqual(['Els']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark anything delivered when the device reports no tabs', async () => {
|
||||||
|
const { service, tx } = createService();
|
||||||
|
|
||||||
|
await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||||
|
|
||||||
|
expect(tx.plannedTab.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DevicesService.sync live update signals', () => {
|
||||||
|
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
|
||||||
|
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
|
||||||
|
|
||||||
|
it('sends nothing when the sync changed nothing', async () => {
|
||||||
|
const { service, realtime } = createService({ previous: [janSnapshot] });
|
||||||
|
|
||||||
|
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
|
||||||
|
|
||||||
|
expect(realtime.notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signals "tabs" when a tab changed', async () => {
|
||||||
|
const { service, realtime } = createService({ previous: [janSnapshot] });
|
||||||
|
|
||||||
|
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, version: 3, totalInCents: 900, itemCount: 2 }] });
|
||||||
|
|
||||||
|
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signals "tabs" when a tab disappeared', async () => {
|
||||||
|
const { service, realtime } = createService({ previous: [janSnapshot] });
|
||||||
|
|
||||||
|
await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||||
|
|
||||||
|
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signals delivered customers and expired commands', async () => {
|
||||||
|
const { service, realtime } = createService({ previous: [janSnapshot], delivered: 1, expired: 2 });
|
||||||
|
|
||||||
|
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
|
||||||
|
|
||||||
|
expect(realtime.notify).toHaveBeenCalledWith('v1', 'planned-tabs', 'commands');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signals "device" when a terminal comes back online', async () => {
|
||||||
|
const { service, realtime } = createService({ previous: [janSnapshot] });
|
||||||
|
|
||||||
|
await service.sync({ ...device, lastSeenAt: new Date(Date.now() - 10 * 60 * 1000) }, { deviceId: 'd1', openTabs: [jan] });
|
||||||
|
|
||||||
|
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||||
|
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
|
||||||
import { SyncDto } from './dto/sync.dto';
|
import { SyncDto } from './dto/sync.dto';
|
||||||
import { AckCommandDto } from './dto/ack-command.dto';
|
import { AckCommandDto } from './dto/ack-command.dto';
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ export class DevicesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
|
private realtime: RealtimeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sync(device: any, dto: SyncDto) {
|
async sync(device: any, dto: SyncDto) {
|
||||||
@@ -29,6 +32,21 @@ export class DevicesService {
|
|||||||
const openTabs = dto.openTabs || [];
|
const openTabs = dto.openTabs || [];
|
||||||
const openTabIds = openTabs.map((t) => t.id);
|
const openTabIds = openTabs.map((t) => t.id);
|
||||||
|
|
||||||
|
// Devices sync often, so dashboards are only told about what actually changed
|
||||||
|
const previous = await this.prisma.openTabSnapshot.findMany({
|
||||||
|
where: { deviceId: device.id },
|
||||||
|
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true },
|
||||||
|
});
|
||||||
|
const previousById = new Map(previous.map((s) => [s.tabId, s]));
|
||||||
|
const tabsChanged =
|
||||||
|
previous.length !== openTabs.length ||
|
||||||
|
openTabs.some((t) => {
|
||||||
|
const p = previousById.get(t.id);
|
||||||
|
return !p || p.customerName !== t.customerName || p.version !== t.version
|
||||||
|
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount;
|
||||||
|
});
|
||||||
|
let plannedDelivered = 0;
|
||||||
|
|
||||||
// Ownership changes are detected up front so audit logging stays outside the transaction
|
// Ownership changes are detected up front so audit logging stays outside the transaction
|
||||||
const takenOver = await this.prisma.tabOwnership.findMany({
|
const takenOver = await this.prisma.tabOwnership.findMany({
|
||||||
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
|
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
|
||||||
@@ -90,6 +108,19 @@ export class DevicesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A planned customer counts as delivered once the device reports a tab with that exact name
|
||||||
|
// (the terminal matches names exactly when creating tabs)
|
||||||
|
if (openTabs.length > 0) {
|
||||||
|
({ count: plannedDelivered } = await tx.plannedTab.updateMany({
|
||||||
|
where: {
|
||||||
|
venueId: device.venueId,
|
||||||
|
deliveredAt: null,
|
||||||
|
customerName: { in: openTabs.map((t) => t.customerName) },
|
||||||
|
},
|
||||||
|
data: { deliveredAt: now },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
await tx.device.update({
|
await tx.device.update({
|
||||||
where: { id: device.id },
|
where: { id: device.id },
|
||||||
data: { lastSeenAt: now },
|
data: { lastSeenAt: now },
|
||||||
@@ -97,7 +128,7 @@ export class DevicesService {
|
|||||||
}, { timeout: 15000 });
|
}, { timeout: 15000 });
|
||||||
|
|
||||||
// Expire old commands
|
// Expire old commands
|
||||||
await this.prisma.remoteCommand.updateMany({
|
const expired = await this.prisma.remoteCommand.updateMany({
|
||||||
where: {
|
where: {
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -131,13 +162,21 @@ export class DevicesService {
|
|||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch planned tab names for this venue
|
// Only customers the terminal has not opened a tab for yet
|
||||||
const plannedTabs = await this.prisma.plannedTab.findMany({
|
const plannedTabs = await this.prisma.plannedTab.findMany({
|
||||||
where: { venueId: device.venueId },
|
where: { venueId: device.venueId, deliveredAt: null },
|
||||||
orderBy: { position: 'asc' },
|
orderBy: { position: 'asc' },
|
||||||
select: { customerName: true },
|
select: { customerName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const topics: RealtimeTopic[] = [];
|
||||||
|
if (tabsChanged) topics.push('tabs');
|
||||||
|
if (plannedDelivered > 0) topics.push('planned-tabs');
|
||||||
|
if (expired.count > 0) topics.push('commands');
|
||||||
|
// Only when the device comes back online; dashboards poll the device status themselves
|
||||||
|
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
|
||||||
|
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cursor,
|
cursor,
|
||||||
commands: commands.map((c) => ({
|
commands: commands.map((c) => ({
|
||||||
@@ -198,6 +237,8 @@ export class DevicesService {
|
|||||||
{ commandId, status: dto.status, result: dto.result },
|
{ commandId, status: dto.status, result: dto.result },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.realtime.notify(device.venueId, 'commands', 'tabs');
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +270,8 @@ export class DevicesService {
|
|||||||
|
|
||||||
await this.auditService.log(venueId, 'system', deviceId, 'device_revoked', { deviceName: device.name });
|
await this.auditService.log(venueId, 'system', deviceId, 'device_revoked', { deviceName: device.name });
|
||||||
|
|
||||||
|
this.realtime.notify(venueId, 'device', 'commands');
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommandExpiryJob {
|
export class CommandExpiryJob {
|
||||||
private readonly logger = new Logger(CommandExpiryJob.name);
|
private readonly logger = new Logger(CommandExpiryJob.name);
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private realtime: RealtimeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_HOUR)
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
async handleExpiredCommands() {
|
async handleExpiredCommands() {
|
||||||
@@ -48,6 +52,10 @@ export class CommandExpiryJob {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const venueId of new Set(expiredCommands.map((c) => c.venueId))) {
|
||||||
|
this.realtime.notify(venueId, 'commands');
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`Expired ${expiredCommands.length} commands`);
|
this.logger.log(`Expired ${expiredCommands.length} commands`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ function createService(initial: Omit<Row, 'venueId'>[]) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) };
|
const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) };
|
||||||
const service = new PlannedTabsService(prisma as any);
|
const service = new PlannedTabsService(prisma as any, { notify: jest.fn() } as any);
|
||||||
return { service, rows: () => rows };
|
return { service, rows: () => rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlannedTabsService {
|
export class PlannedTabsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private realtime: RealtimeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(venueId: string) {
|
async findAll(venueId: string) {
|
||||||
const tabs = await this.prisma.plannedTab.findMany({
|
const tabs = await this.prisma.plannedTab.findMany({
|
||||||
where: { venueId },
|
where: { venueId },
|
||||||
orderBy: { position: 'asc' },
|
orderBy: { position: 'asc' },
|
||||||
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
select: { id: true, customerName: true, email: true, position: true, createdAt: true, deliveredAt: true },
|
||||||
});
|
});
|
||||||
return { plannedTabs: tabs };
|
return { plannedTabs: tabs };
|
||||||
}
|
}
|
||||||
@@ -44,6 +48,7 @@ export class PlannedTabsService {
|
|||||||
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.realtime.notify(venueId, 'planned-tabs');
|
||||||
return tab;
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ export class PlannedTabsService {
|
|||||||
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +92,7 @@ export class PlannedTabsService {
|
|||||||
incoming.set(key, { customerName, email: entry.email || previous?.email });
|
incoming.set(key, { customerName, email: entry.email || previous?.email });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
const existing = await tx.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' } });
|
const 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) => [t.customerName.toLowerCase(), t]));
|
||||||
let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
|
let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
|
||||||
@@ -120,6 +126,9 @@ export class PlannedTabsService {
|
|||||||
|
|
||||||
return { created, updated, unchanged, removed };
|
return { created, updated, unchanged, removed };
|
||||||
}, { timeout: 30000 });
|
}, { timeout: 30000 });
|
||||||
|
|
||||||
|
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(venueId: string, id: string) {
|
async remove(venueId: string, id: string) {
|
||||||
@@ -127,6 +136,7 @@ export class PlannedTabsService {
|
|||||||
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
|
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
|
||||||
|
|
||||||
await this.prisma.plannedTab.delete({ where: { id } });
|
await this.prisma.plannedTab.delete({ where: { id } });
|
||||||
|
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { OnGatewayConnection, OnGatewayInit, WebSocketGateway } from '@nestjs/websockets';
|
||||||
|
import { Namespace, Socket } from 'socket.io';
|
||||||
|
import { RealtimeService, venueRoom } from './realtime.service';
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
namespace: '/realtime',
|
||||||
|
cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:3000', credentials: true },
|
||||||
|
})
|
||||||
|
export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
|
||||||
|
constructor(
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private realtime: RealtimeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
afterInit(namespace: Namespace) {
|
||||||
|
this.realtime.attach(namespace);
|
||||||
|
|
||||||
|
// Authenticate during the handshake so rejected clients never count as connected;
|
||||||
|
// the dashboard refreshes its access token on `unauthorized` and reconnects
|
||||||
|
namespace.use(async (socket, next) => {
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync(String(socket.handshake.auth?.token ?? ''));
|
||||||
|
socket.data.venueId = payload.venueId;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
next(new Error('unauthorized'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConnection(client: Socket) {
|
||||||
|
await client.join(venueRoom(client.data.venueId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { getJwtSecret } from '../auth/jwt-secret';
|
||||||
|
import { RealtimeGateway } from './realtime.gateway';
|
||||||
|
import { RealtimeService } from './realtime.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [JwtModule.register({ secret: getJwtSecret() })],
|
||||||
|
providers: [RealtimeGateway, RealtimeService],
|
||||||
|
exports: [RealtimeService],
|
||||||
|
})
|
||||||
|
export class RealtimeModule {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Namespace } from 'socket.io';
|
||||||
|
|
||||||
|
/** What changed; the dashboard refetches the matching data over the normal API. */
|
||||||
|
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users';
|
||||||
|
|
||||||
|
export const venueRoom = (venueId: string) => `venue:${venueId}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells connected dashboards of a venue that data changed. Events only carry topic names,
|
||||||
|
* never data, so a socket can't see anything its owner couldn't fetch through the API.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RealtimeService {
|
||||||
|
private namespace: Namespace | null = null;
|
||||||
|
|
||||||
|
attach(namespace: Namespace) {
|
||||||
|
this.namespace = namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(venueId: string, ...topics: RealtimeTopic[]) {
|
||||||
|
this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
|
|
||||||
// Tabs stay open per customer (closing only settles them), so a customer can be reminded again
|
// 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.
|
// once this many days have passed since their last reminder.
|
||||||
@@ -19,7 +20,10 @@ export class RemindersService {
|
|||||||
private resendApiKey: string;
|
private resendApiKey: string;
|
||||||
private fromEmail: string;
|
private fromEmail: string;
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private realtime: RealtimeService,
|
||||||
|
) {
|
||||||
this.resendApiKey = process.env.RESEND_API_KEY || '';
|
this.resendApiKey = process.env.RESEND_API_KEY || '';
|
||||||
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
|
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
|
||||||
}
|
}
|
||||||
@@ -137,6 +141,8 @@ export class RemindersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sent > 0) this.realtime.notify(venueId, 'reminders');
|
||||||
|
|
||||||
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
|
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export class TabsService {
|
|||||||
});
|
});
|
||||||
const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s]));
|
const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s]));
|
||||||
|
|
||||||
|
// Customer details (email for reminders) live on the planned customer with the same name
|
||||||
|
const planned = await this.prisma.plannedTab.findMany({
|
||||||
|
where: { venueId },
|
||||||
|
select: { id: true, customerName: true, email: true },
|
||||||
|
});
|
||||||
|
const plannedByName = new Map(planned.map((p) => [p.customerName, p]));
|
||||||
|
|
||||||
const tabs = [];
|
const tabs = [];
|
||||||
for (const ownership of ownerships) {
|
for (const ownership of ownerships) {
|
||||||
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
|
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
|
||||||
@@ -38,6 +45,8 @@ export class TabsService {
|
|||||||
deviceLastSeenAt: ownership.device.lastSeenAt,
|
deviceLastSeenAt: ownership.device.lastSeenAt,
|
||||||
deviceRevokedAt: ownership.device.revokedAt,
|
deviceRevokedAt: ownership.device.revokedAt,
|
||||||
lastReportedAt: snapshot.reportedAt,
|
lastReportedAt: snapshot.reportedAt,
|
||||||
|
plannedTabId: plannedByName.get(snapshot.customerName)?.id ?? null,
|
||||||
|
email: plannedByName.get(snapshot.customerName)?.email ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.49.0",
|
"react-hook-form": "^7.49.0",
|
||||||
|
"socket.io-client": "^4.8.4",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { LoadingState } from '@/components/loading-state';
|
|||||||
import { ErrorState } from '@/components/error-state';
|
import { ErrorState } from '@/components/error-state';
|
||||||
import { formatRelativeTime, formatDateTime } from '@/lib/utils';
|
import { formatRelativeTime, formatDateTime } from '@/lib/utils';
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter } from 'lucide-react';
|
||||||
|
import { useRealtime } from '@/lib/realtime';
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
{ value: '', label: 'All' },
|
{ value: '', label: 'All' },
|
||||||
@@ -26,6 +27,8 @@ const statusFilters = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function CommandsPage() {
|
export default function CommandsPage() {
|
||||||
|
// Live updates make polling unnecessary; it only runs while the socket is down
|
||||||
|
const { connected: live } = useRealtime();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [venueId, setVenueId] = useState<string | null>(null);
|
const [venueId, setVenueId] = useState<string | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
@@ -49,7 +52,7 @@ export default function CommandsPage() {
|
|||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api(`/venues/${venueId}/commands?limit=${limit}&offset=${page * limit}${statusFilter ? `&status=${statusFilter}` : ''}`),
|
api(`/venues/${venueId}/commands?limit=${limit}&offset=${page * limit}${statusFilter ? `&status=${statusFilter}` : ''}`),
|
||||||
enabled: !!venueId,
|
enabled: !!venueId,
|
||||||
refetchInterval: 10000,
|
refetchInterval: live ? false : 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const commands = data?.commands || [];
|
const commands = data?.commands || [];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useLang } from '@/lib/i18n';
|
|||||||
import { LanguageSelector } from '@/components/language-selector';
|
import { LanguageSelector } from '@/components/language-selector';
|
||||||
import { authApi, api } from '@/lib/api/client';
|
import { authApi, api } from '@/lib/api/client';
|
||||||
import { toast } from '@/components/ui/toast';
|
import { toast } from '@/components/ui/toast';
|
||||||
|
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
|
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
|
||||||
@@ -78,6 +79,7 @@ export default function DashboardLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<RealtimeProvider>
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Mobile sidebar toggle */}
|
{/* Mobile sidebar toggle */}
|
||||||
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between h-14 px-4 border-b bg-background">
|
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between h-14 px-4 border-b bg-background">
|
||||||
@@ -140,6 +142,7 @@ export default function DashboardLayout({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
|
<LiveIndicator />
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="mb-3 text-sm">
|
<div className="mb-3 text-sm">
|
||||||
@@ -164,5 +167,17 @@ export default function DashboardLayout({
|
|||||||
<div className="p-6 max-w-7xl mx-auto">{children}</div>
|
<div className="p-6 max-w-7xl mx-auto">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</RealtimeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveIndicator() {
|
||||||
|
const { connected } = useRealtime();
|
||||||
|
const { t } = useLang();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
|
||||||
|
<span className={cn('h-2 w-2 rounded-full', connected ? 'bg-green-500' : 'bg-muted-foreground/40')} />
|
||||||
|
{connected ? t('realtime.live') : t('realtime.offline')}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,11 @@ import { LoadingState } from '@/components/loading-state';
|
|||||||
import { CommandStatusBadge } from '@/components/command-status-badge';
|
import { CommandStatusBadge } from '@/components/command-status-badge';
|
||||||
import { formatRelativeTime } from '@/lib/utils';
|
import { formatRelativeTime } from '@/lib/utils';
|
||||||
import { ClipboardList, Terminal, Monitor } from 'lucide-react';
|
import { ClipboardList, Terminal, Monitor } from 'lucide-react';
|
||||||
|
import { useRealtime } from '@/lib/realtime';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
// Live updates make polling unnecessary; it only runs while the socket is down
|
||||||
|
const { connected: live } = useRealtime();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [venueId, setVenueId] = useState<string | null>(null);
|
const [venueId, setVenueId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -32,13 +35,14 @@ export default function DashboardPage() {
|
|||||||
queryKey: ['tabs', venueId],
|
queryKey: ['tabs', venueId],
|
||||||
queryFn: () => api(`/venues/${venueId}/tabs`),
|
queryFn: () => api(`/venues/${venueId}/tabs`),
|
||||||
enabled: !!venueId,
|
enabled: !!venueId,
|
||||||
refetchInterval: 10000,
|
refetchInterval: live ? false : 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deviceQuery = useQuery({
|
const deviceQuery = useQuery({
|
||||||
queryKey: ['device', venueId],
|
queryKey: ['device', venueId],
|
||||||
queryFn: () => api(`/venues/${venueId}/devices`),
|
queryFn: () => api(`/venues/${venueId}/devices`),
|
||||||
enabled: !!venueId,
|
enabled: !!venueId,
|
||||||
|
// A terminal going offline sends no event, so its status is always polled
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ export default function DashboardPage() {
|
|||||||
queryKey: ['commands', venueId, 'recent'],
|
queryKey: ['commands', venueId, 'recent'],
|
||||||
queryFn: () => api(`/venues/${venueId}/commands?limit=5`),
|
queryFn: () => api(`/venues/${venueId}/commands?limit=5`),
|
||||||
enabled: !!venueId,
|
enabled: !!venueId,
|
||||||
refetchInterval: 15000,
|
refetchInterval: live ? false : 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!venueId) return <LoadingState />;
|
if (!venueId) return <LoadingState />;
|
||||||
@@ -60,7 +64,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Overview" description="Real-time overview of your venue" />
|
<PageHeader title="Overview" description="" />
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||||
|
|||||||
@@ -130,16 +130,12 @@ export default function RemindersPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-xs text-muted-foreground mb-4">
|
|
||||||
{t('reminders.candidates_desc')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{candidatesQuery.isLoading ? (
|
{candidatesQuery.isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
|
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||||
) : candidatesQuery.error ? (
|
) : candidatesQuery.error ? (
|
||||||
<p className="text-sm text-destructive">{t('common.error')}</p>
|
<p className="text-sm text-destructive">{t('common.error')}</p>
|
||||||
) : candidates.length === 0 ? (
|
) : candidates.length === 0 ? (
|
||||||
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
|
<EmptyState title={t('reminders.no_candidates')} description='' />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-md border mb-4">
|
<div className="rounded-md border mb-4">
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default function SettingsPage() {
|
|||||||
queryKey: ['device', venueId],
|
queryKey: ['device', venueId],
|
||||||
queryFn: () => devicesApi.get(venueId!),
|
queryFn: () => devicesApi.get(venueId!),
|
||||||
enabled: !!venueId,
|
enabled: !!venueId,
|
||||||
|
// A terminal going offline sends no event, so its status is always polled
|
||||||
refetchInterval: 15000,
|
refetchInterval: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import { formatCents, formatRelativeTime } from '@/lib/utils';
|
|||||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
|
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
|
||||||
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
||||||
import { useLang } from '@/lib/i18n';
|
import { useLang } from '@/lib/i18n';
|
||||||
|
import { useRealtime } from '@/lib/realtime';
|
||||||
|
|
||||||
export default function TabsPage() {
|
export default function TabsPage() {
|
||||||
|
// Live updates make polling unnecessary; it only runs while the socket is down
|
||||||
|
const { connected: live } = useRealtime();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [venueId, setVenueId] = useState<string | null>(null);
|
const [venueId, setVenueId] = useState<string | null>(null);
|
||||||
@@ -47,22 +50,26 @@ export default function TabsPage() {
|
|||||||
queryKey: ['tabs', venueId],
|
queryKey: ['tabs', venueId],
|
||||||
queryFn: () => api(`/venues/${venueId}/tabs`),
|
queryFn: () => api(`/venues/${venueId}/tabs`),
|
||||||
enabled: !!venueId,
|
enabled: !!venueId,
|
||||||
refetchInterval: 10000,
|
refetchInterval: live ? false : 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const commandsQuery = useQuery({
|
const commandsQuery = useQuery({
|
||||||
queryKey: ['commands', venueId, 'pending'],
|
queryKey: ['commands', venueId, 'pending'],
|
||||||
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
|
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
|
||||||
enabled: !!venueId && pendingCommandIds.size > 0,
|
enabled: !!venueId && pendingCommandIds.size > 0,
|
||||||
refetchInterval: 3000,
|
refetchInterval: live ? false : 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const plannedQuery = useQuery({
|
const plannedQuery = useQuery({
|
||||||
queryKey: ['planned-tabs', venueId],
|
queryKey: ['planned-tabs', venueId],
|
||||||
queryFn: () => plannedTabsApi.list(venueId!),
|
queryFn: () => plannedTabsApi.list(venueId!),
|
||||||
enabled: !!venueId,
|
enabled: !!venueId,
|
||||||
|
refetchInterval: live ? false : 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tiles only show customers the terminal has not opened a tab for yet
|
||||||
|
const pendingPlanned = (plannedQuery.data?.plannedTabs || []).filter((pt: any) => !pt.deliveredAt);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (commandsQuery.data?.commands) {
|
if (commandsQuery.data?.commands) {
|
||||||
for (const cmd of commandsQuery.data.commands) {
|
for (const cmd of commandsQuery.data.commands) {
|
||||||
@@ -158,9 +165,6 @@ export default function TabsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
|
||||||
These names are synced to the terminal. It will create tabs for these customers automatically.
|
|
||||||
</p>
|
|
||||||
{userRole === 'admin' && (
|
{userRole === 'admin' && (
|
||||||
<div className="flex gap-2 mb-3 flex-wrap">
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
<Input
|
<Input
|
||||||
@@ -184,10 +188,10 @@ export default function TabsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{plannedQuery.data?.plannedTabs?.length === 0 && (
|
{plannedQuery.data && pendingPlanned.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">No planned customers. Add names above and they'll sync to the terminal.</p>
|
<p className="text-sm text-muted-foreground">No customers waiting for the terminal.</p>
|
||||||
)}
|
)}
|
||||||
{(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
|
{pendingPlanned.map((pt: any) => (
|
||||||
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
|
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
|
||||||
{pt.customerName}
|
{pt.customerName}
|
||||||
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
|
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
|
||||||
@@ -213,13 +217,14 @@ export default function TabsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{tabs.length === 0 ? (
|
{tabs.length === 0 ? (
|
||||||
<EmptyState title="No open tabs" description="Open tabs will appear here when the terminal syncs." />
|
<EmptyState title="No open tabs" description="" />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Customer</TableHead>
|
<TableHead>Customer</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Amount</TableHead>
|
<TableHead>Amount</TableHead>
|
||||||
<TableHead>Items</TableHead>
|
<TableHead>Items</TableHead>
|
||||||
<TableHead>Version</TableHead>
|
<TableHead>Version</TableHead>
|
||||||
@@ -231,6 +236,26 @@ export default function TabsPage() {
|
|||||||
{tabs.map((tab: any) => (
|
{tabs.map((tab: any) => (
|
||||||
<TableRow key={tab.tabId}>
|
<TableRow key={tab.tabId}>
|
||||||
<TableCell className="font-medium">{tab.customerName}</TableCell>
|
<TableCell className="font-medium">{tab.customerName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{tab.email ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
<span className="truncate max-w-[200px]">{tab.email}</span>
|
||||||
|
{userRole === 'admin' && tab.plannedTabId && (
|
||||||
|
<button
|
||||||
|
onClick={() => sendTestMutation.mutate(tab.plannedTabId)}
|
||||||
|
disabled={sendTestMutation.isPending}
|
||||||
|
className="hover:text-primary"
|
||||||
|
title="Send test email"
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
|
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
|
||||||
<TableCell>{tab.itemCount}</TableCell>
|
<TableCell>{tab.itemCount}</TableCell>
|
||||||
<TableCell>{tab.version}</TableCell>
|
<TableCell>{tab.version}</TableCell>
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export default function LoginPage() {
|
|||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="space-y-1 text-center">
|
<CardHeader className="space-y-1 text-center">
|
||||||
<CardTitle className="text-2xl font-bold">KoolTap</CardTitle>
|
<CardTitle className="text-2xl font-bold">KoolTap</CardTitle>
|
||||||
<CardDescription>Remote Tab Control Dashboard</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -45,7 +44,6 @@ export default function LoginPage() {
|
|||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="admin@example.com"
|
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -57,7 +55,6 @@ export default function LoginPage() {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
interface ApiError {
|
interface ApiError {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -208,4 +208,8 @@ export const remindersApi = {
|
|||||||
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
|
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export { setTokens, clearTokens };
|
export { setTokens, clearTokens, refreshAccessToken };
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return getTokens().accessToken ?? null;
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'nav.commands': 'Command History',
|
'nav.commands': 'Command History',
|
||||||
'nav.settings': 'Settings',
|
'nav.settings': 'Settings',
|
||||||
'nav.logout': 'Logout',
|
'nav.logout': 'Logout',
|
||||||
|
'realtime.live': 'Live updates on',
|
||||||
|
'realtime.offline': 'Live updates unavailable — refreshing periodically',
|
||||||
'tabs.title': 'Open Tabs',
|
'tabs.title': 'Open Tabs',
|
||||||
'tabs.count': '{count} open tab',
|
'tabs.count': '{count} open tab',
|
||||||
'tabs.count_plural': '{count} open tabs',
|
'tabs.count_plural': '{count} open tabs',
|
||||||
@@ -168,6 +170,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'nav.commands': 'Opdrachten',
|
'nav.commands': 'Opdrachten',
|
||||||
'nav.settings': 'Instellingen',
|
'nav.settings': 'Instellingen',
|
||||||
'nav.logout': 'Uitloggen',
|
'nav.logout': 'Uitloggen',
|
||||||
|
'realtime.live': 'Live updates aan',
|
||||||
|
'realtime.offline': 'Live updates niet beschikbaar — wordt periodiek vernieuwd',
|
||||||
'tabs.title': 'Open Tabs',
|
'tabs.title': 'Open Tabs',
|
||||||
'tabs.count': '{count} open tab',
|
'tabs.count': '{count} open tab',
|
||||||
'tabs.count_plural': '{count} open tabs',
|
'tabs.count_plural': '{count} open tabs',
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client';
|
||||||
|
|
||||||
|
// Server topics mapped to the dashboard queries that show that data
|
||||||
|
const TOPIC_QUERIES: Record<string, string[][]> = {
|
||||||
|
tabs: [['tabs'], ['reminder-candidates']],
|
||||||
|
commands: [['commands']],
|
||||||
|
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
|
||||||
|
device: [['device']],
|
||||||
|
reminders: [['reminder-candidates'], ['reminder-history']],
|
||||||
|
users: [['users']],
|
||||||
|
};
|
||||||
|
|
||||||
|
const RealtimeContext = createContext({ connected: false });
|
||||||
|
|
||||||
|
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = io(`${new URL(API_URL).origin}/realtime`, {
|
||||||
|
// Read on every (re)connect so a refreshed token is used
|
||||||
|
auth: (cb) => cb({ token: getAccessToken() }),
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasConnected = false;
|
||||||
|
let refreshing = false;
|
||||||
|
let rejections = 0;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setConnected(true);
|
||||||
|
rejections = 0;
|
||||||
|
// Changes may have been missed while disconnected
|
||||||
|
if (hasConnected) queryClient.invalidateQueries();
|
||||||
|
hasConnected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => setConnected(false));
|
||||||
|
|
||||||
|
socket.on('invalidate', ({ topics }: { topics: string[] }) => {
|
||||||
|
for (const topic of topics) {
|
||||||
|
for (const queryKey of TOPIC_QUERIES[topic] ?? []) {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The access token expired: refresh it and reconnect (rejected handshakes are not retried
|
||||||
|
// automatically). If refreshing fails, the next API call sends the user to the login page.
|
||||||
|
socket.on('connect_error', async (error) => {
|
||||||
|
if (error.message !== 'unauthorized' || refreshing || ++rejections > 3) return;
|
||||||
|
refreshing = true;
|
||||||
|
const token = await refreshAccessToken();
|
||||||
|
refreshing = false;
|
||||||
|
if (token) socket.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRealtime() {
|
||||||
|
return useContext(RealtimeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polling interval to use only while live updates are unavailable. */
|
||||||
|
export function usePollingFallback(intervalMs: number): number | false {
|
||||||
|
const { connected } = useRealtime();
|
||||||
|
return connected ? false : intervalMs;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -43,6 +43,9 @@ Sync semantics:
|
|||||||
- Every sync returns **all** pending commands for the device; `cursor` is still returned but no longer
|
- Every sync returns **all** pending commands for the device; `cursor` is still returned but no longer
|
||||||
filters. Devices dedupe by command id and re-send the ack for commands they already processed.
|
filters. Devices dedupe by command id and re-send the ack for commands they already processed.
|
||||||
- The first ack for a command wins; later acks are accepted as no-ops.
|
- The first ack for a command wins; later acks are accepted as no-ops.
|
||||||
|
- `plannedTabNames` only contains planned customers the device has not opened a tab for yet. Once a sync
|
||||||
|
reports an open tab with exactly that name, the customer is marked delivered and no longer sent.
|
||||||
|
The customer (and their email, used for reminders) is kept.
|
||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
`GET /venues/:venueId/tabs` - List open tabs
|
`GET /venues/:venueId/tabs` - List open tabs
|
||||||
|
|||||||
Generated
+224
-11
@@ -22,7 +22,7 @@ importers:
|
|||||||
version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^10.3.0
|
specifier: ^10.3.0
|
||||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/jwt':
|
'@nestjs/jwt':
|
||||||
specifier: ^10.2.0
|
specifier: ^10.2.0
|
||||||
version: 10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))
|
version: 10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))
|
||||||
@@ -32,6 +32,9 @@ importers:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^10.3.0
|
specifier: ^10.3.0
|
||||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(supports-color@8.1.1)
|
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(supports-color@8.1.1)
|
||||||
|
'@nestjs/platform-socket.io':
|
||||||
|
specifier: ^10.4.22
|
||||||
|
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@10.4.22)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
'@nestjs/schedule':
|
'@nestjs/schedule':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)
|
version: 4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)
|
||||||
@@ -41,6 +44,9 @@ importers:
|
|||||||
'@nestjs/throttler':
|
'@nestjs/throttler':
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)
|
version: 5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)
|
||||||
|
'@nestjs/websockets':
|
||||||
|
specifier: ^10.4.22
|
||||||
|
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(@nestjs/platform-socket.io@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.10.0
|
specifier: ^5.10.0
|
||||||
version: 5.22.0(prisma@5.22.0)
|
version: 5.22.0(prisma@5.22.0)
|
||||||
@@ -74,6 +80,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
socket.io:
|
||||||
|
specifier: ^4.8.1
|
||||||
|
version: 4.8.1(supports-color@8.1.1)
|
||||||
ts-node:
|
ts-node:
|
||||||
specifier: ^10.9.2
|
specifier: ^10.9.2
|
||||||
version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3)
|
version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3)
|
||||||
@@ -179,7 +188,7 @@ importers:
|
|||||||
version: 0.309.0(react@18.3.1)
|
version: 0.309.0(react@18.3.1)
|
||||||
next:
|
next:
|
||||||
specifier: ^14.1.0
|
specifier: ^14.1.0
|
||||||
version: 14.2.35(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.35(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -189,6 +198,9 @@ importers:
|
|||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.49.0
|
specifier: ^7.49.0
|
||||||
version: 7.88.0(react@18.3.1)
|
version: 7.88.0(react@18.3.1)
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.4
|
||||||
|
version: 4.8.4(supports-color@8.1.1)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.6.1
|
version: 2.6.1
|
||||||
@@ -786,6 +798,13 @@ packages:
|
|||||||
'@nestjs/common': ^10.0.0
|
'@nestjs/common': ^10.0.0
|
||||||
'@nestjs/core': ^10.0.0
|
'@nestjs/core': ^10.0.0
|
||||||
|
|
||||||
|
'@nestjs/platform-socket.io@10.4.22':
|
||||||
|
resolution: {integrity: sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0
|
||||||
|
'@nestjs/websockets': ^10.0.0
|
||||||
|
rxjs: ^7.1.0
|
||||||
|
|
||||||
'@nestjs/schedule@4.1.2':
|
'@nestjs/schedule@4.1.2':
|
||||||
resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==}
|
resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -834,6 +853,18 @@ packages:
|
|||||||
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
|
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||||
reflect-metadata: ^0.1.13 || ^0.2.0
|
reflect-metadata: ^0.1.13 || ^0.2.0
|
||||||
|
|
||||||
|
'@nestjs/websockets@10.4.22':
|
||||||
|
resolution: {integrity: sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0
|
||||||
|
'@nestjs/core': ^10.0.0
|
||||||
|
'@nestjs/platform-socket.io': ^10.0.0
|
||||||
|
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||||
|
rxjs: ^7.1.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@nestjs/platform-socket.io':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@next/env@14.2.35':
|
'@next/env@14.2.35':
|
||||||
resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
|
resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
|
||||||
|
|
||||||
@@ -1364,6 +1395,9 @@ packages:
|
|||||||
'@sinonjs/fake-timers@10.3.0':
|
'@sinonjs/fake-timers@10.3.0':
|
||||||
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2':
|
||||||
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
'@swc/counter@0.1.3':
|
'@swc/counter@0.1.3':
|
||||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||||
|
|
||||||
@@ -1418,6 +1452,9 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||||
|
|
||||||
@@ -1525,6 +1562,9 @@ packages:
|
|||||||
'@types/validator@13.15.10':
|
'@types/validator@13.15.10':
|
||||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||||
|
|
||||||
|
'@types/ws@8.18.2':
|
||||||
|
resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
|
||||||
|
|
||||||
'@types/yargs-parser@21.0.3':
|
'@types/yargs-parser@21.0.3':
|
||||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||||
|
|
||||||
@@ -1749,6 +1789,10 @@ packages:
|
|||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
|
base64id@2.0.0:
|
||||||
|
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||||
|
engines: {node: ^4.5.0 || >= 5.9}
|
||||||
|
|
||||||
baseline-browser-mapping@2.11.24:
|
baseline-browser-mapping@2.11.24:
|
||||||
resolution: {integrity: sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==}
|
resolution: {integrity: sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -2034,6 +2078,15 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
debug@4.3.7:
|
||||||
|
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -2135,6 +2188,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
engine.io-client@6.6.7:
|
||||||
|
resolution: {integrity: sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==}
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3:
|
||||||
|
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
engine.io@6.6.11:
|
||||||
|
resolution: {integrity: sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==}
|
||||||
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
enhanced-resolve@5.25.1:
|
enhanced-resolve@5.25.1:
|
||||||
resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==}
|
resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -3486,6 +3550,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
socket.io-adapter@2.5.8:
|
||||||
|
resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==}
|
||||||
|
|
||||||
|
socket.io-client@4.8.4:
|
||||||
|
resolution: {integrity: sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
socket.io-parser@4.2.7:
|
||||||
|
resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
socket.io@4.8.1:
|
||||||
|
resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==}
|
||||||
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3970,12 +4049,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
|
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||||
|
|
||||||
|
ws@8.21.3:
|
||||||
|
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz:
|
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz:
|
||||||
resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz}
|
resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz}
|
||||||
version: 0.20.3
|
version: 0.20.3
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2:
|
||||||
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@@ -4620,7 +4715,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
'@nuxtjs/opencollective': 0.3.2
|
'@nuxtjs/opencollective': 0.3.2
|
||||||
@@ -4633,6 +4728,7 @@ snapshots:
|
|||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(supports-color@8.1.1)
|
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(supports-color@8.1.1)
|
||||||
|
'@nestjs/websockets': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(@nestjs/platform-socket.io@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
|
|
||||||
@@ -4658,7 +4754,7 @@ snapshots:
|
|||||||
'@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(supports-color@8.1.1)':
|
'@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
body-parser: 1.20.4(supports-color@8.1.1)
|
body-parser: 1.20.4(supports-color@8.1.1)
|
||||||
cors: 2.8.5
|
cors: 2.8.5
|
||||||
express: 4.22.1(supports-color@8.1.1)
|
express: 4.22.1(supports-color@8.1.1)
|
||||||
@@ -4667,10 +4763,22 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@nestjs/platform-socket.io@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@10.4.22)(rxjs@7.8.2)(supports-color@8.1.1)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
|
'@nestjs/websockets': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(@nestjs/platform-socket.io@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
rxjs: 7.8.2
|
||||||
|
socket.io: 4.8.1(supports-color@8.1.1)
|
||||||
|
tslib: 2.8.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)':
|
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
cron: 3.2.1
|
cron: 3.2.1
|
||||||
uuid: 11.0.3
|
uuid: 11.0.3
|
||||||
|
|
||||||
@@ -4700,7 +4808,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@microsoft/tsdoc': 0.15.1
|
'@microsoft/tsdoc': 0.15.1
|
||||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
'@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
js-yaml: 4.1.0
|
js-yaml: 4.1.0
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
@@ -4714,7 +4822,7 @@ snapshots:
|
|||||||
'@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)':
|
'@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(supports-color@8.1.1)
|
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(supports-color@8.1.1)
|
||||||
@@ -4722,9 +4830,21 @@ snapshots:
|
|||||||
'@nestjs/throttler@5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)':
|
'@nestjs/throttler@5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
|
'@nestjs/websockets@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@10.4.22)(@nestjs/platform-socket.io@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
|
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
iterare: 1.2.1
|
||||||
|
object-hash: 3.0.0
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
rxjs: 7.8.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@nestjs/platform-socket.io': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@10.4.22)(rxjs@7.8.2)(supports-color@8.1.1)
|
||||||
|
|
||||||
'@next/env@14.2.35': {}
|
'@next/env@14.2.35': {}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@14.2.33':
|
'@next/swc-darwin-arm64@14.2.33':
|
||||||
@@ -5228,6 +5348,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@sinonjs/commons': 3.0.1
|
'@sinonjs/commons': 3.0.1
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
'@swc/counter@0.1.3': {}
|
'@swc/counter@0.1.3': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.5':
|
'@swc/helpers@0.5.5':
|
||||||
@@ -5294,6 +5416,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.43
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
@@ -5430,6 +5556,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/validator@13.15.10': {}
|
'@types/validator@13.15.10': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.2':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
'@types/yargs-parser@21.0.3': {}
|
'@types/yargs-parser@21.0.3': {}
|
||||||
|
|
||||||
'@types/yargs@17.0.35':
|
'@types/yargs@17.0.35':
|
||||||
@@ -5697,6 +5827,8 @@ snapshots:
|
|||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.11.24: {}
|
baseline-browser-mapping@2.11.24: {}
|
||||||
|
|
||||||
bcrypt@5.1.1(supports-color@8.1.1):
|
bcrypt@5.1.1(supports-color@8.1.1):
|
||||||
@@ -5985,6 +6117,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
|
|
||||||
|
debug@4.3.7(supports-color@8.1.1):
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
optionalDependencies:
|
||||||
|
supports-color: 8.1.1
|
||||||
|
|
||||||
debug@4.4.3(supports-color@8.1.1):
|
debug@4.4.3(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -6056,6 +6194,36 @@ snapshots:
|
|||||||
|
|
||||||
encodeurl@2.0.0: {}
|
encodeurl@2.0.0: {}
|
||||||
|
|
||||||
|
engine.io-client@6.6.7(supports-color@8.1.1):
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
engine.io-parser: 5.2.3
|
||||||
|
ws: 8.21.3
|
||||||
|
xmlhttprequest-ssl: 2.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3: {}
|
||||||
|
|
||||||
|
engine.io@6.6.11(supports-color@8.1.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/cors': 2.8.19
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
'@types/ws': 8.18.2
|
||||||
|
accepts: 1.3.8
|
||||||
|
cookie: 0.7.2
|
||||||
|
cors: 2.8.5
|
||||||
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
engine.io-parser: 5.2.3
|
||||||
|
ws: 8.21.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
enhanced-resolve@5.25.1:
|
enhanced-resolve@5.25.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -7170,7 +7338,7 @@ snapshots:
|
|||||||
|
|
||||||
neo-async@2.6.2: {}
|
neo-async@2.6.2: {}
|
||||||
|
|
||||||
next@14.2.35(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
next@14.2.35(@babel/core@7.29.7(supports-color@8.1.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 14.2.35
|
'@next/env': 14.2.35
|
||||||
'@swc/helpers': 0.5.5
|
'@swc/helpers': 0.5.5
|
||||||
@@ -7180,7 +7348,7 @@ snapshots:
|
|||||||
postcss: 8.4.31
|
postcss: 8.4.31
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
styled-jsx: 5.1.1(@babel/core@7.29.7)(react@18.3.1)
|
styled-jsx: 5.1.1(@babel/core@7.29.7(supports-color@8.1.1))(react@18.3.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 14.2.33
|
'@next/swc-darwin-arm64': 14.2.33
|
||||||
'@next/swc-darwin-x64': 14.2.33
|
'@next/swc-darwin-x64': 14.2.33
|
||||||
@@ -7653,6 +7821,47 @@ snapshots:
|
|||||||
|
|
||||||
slash@3.0.0: {}
|
slash@3.0.0: {}
|
||||||
|
|
||||||
|
socket.io-adapter@2.5.8(supports-color@8.1.1):
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
ws: 8.21.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
socket.io-client@4.8.4(supports-color@8.1.1):
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
engine.io-client: 6.6.7(supports-color@8.1.1)
|
||||||
|
socket.io-parser: 4.2.7(supports-color@8.1.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
socket.io-parser@4.2.7(supports-color@8.1.1):
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
socket.io@4.8.1(supports-color@8.1.1):
|
||||||
|
dependencies:
|
||||||
|
accepts: 1.3.8
|
||||||
|
base64id: 2.0.0
|
||||||
|
cors: 2.8.5
|
||||||
|
debug: 4.3.7(supports-color@8.1.1)
|
||||||
|
engine.io: 6.6.11(supports-color@8.1.1)
|
||||||
|
socket.io-adapter: 2.5.8(supports-color@8.1.1)
|
||||||
|
socket.io-parser: 4.2.7(supports-color@8.1.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.13:
|
source-map-support@0.5.13:
|
||||||
@@ -7722,7 +7931,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
|
|
||||||
styled-jsx@5.1.1(@babel/core@7.29.7)(react@18.3.1):
|
styled-jsx@5.1.1(@babel/core@7.29.7(supports-color@8.1.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -8122,8 +8331,12 @@ snapshots:
|
|||||||
imurmurhash: 0.1.4
|
imurmurhash: 0.1.4
|
||||||
signal-exit: 3.0.7
|
signal-exit: 3.0.7
|
||||||
|
|
||||||
|
ws@8.21.3: {}
|
||||||
|
|
||||||
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {}
|
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {}
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user