diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 88bfd379..4ad54489 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -239,7 +239,14 @@ model User { transactionHistory TransactionHistory[] favorites PropertyFavorite[] propertyViews PropertyView[] - openHouseRsvps OpenHouseRsvp[] + openHouseRsvps OpenHouseRsvp[] + webhooks Webhook[] + tourRequests TourRequest[] @relation("TourRequestRequester") + tourAgentAssignments TourRequest[] @relation("TourRequestAgent") + agentAvailabilities AgentAvailability[] + supportTickets SupportTicket[] @relation("SupportTicketUser") + assignedTickets SupportTicket[] @relation("SupportTicketAgent") + supportTicketNotes SupportTicketNote[] transactionNotes TransactionNote[] @relation("TransactionNoteAuthor") deletedProperties Property[] @relation("DeletedProperties") priceChanges PropertyPriceHistory[] @relation("PriceChangeAuthor") @@ -458,6 +465,7 @@ model Property { favorites PropertyFavorite[] views PropertyView[] openHouses OpenHouse[] + tourRequests TourRequest[] neighborhood Neighborhood? @relation(fields: [neighborhoodId], references: [id], onDelete: SetNull) amenities PropertyAmenity[] deletedBy User? @relation("DeletedProperties", fields: [deletedById], references: [id], onDelete: SetNull) @@ -1328,4 +1336,199 @@ enum JobStatus { PROCESSING COMPLETED FAILED -} \ No newline at end of file +} + +// ─── Webhook Event System (#958) ────────────────────────────────────────────── + +enum WebhookStatus { + ACTIVE + INACTIVE + VERIFYING +} + +enum WebhookDeliveryStatus { + PENDING + SUCCESS + FAILED + RETRYING +} + +model Webhook { + id String @id @default(uuid()) + userId String @map("user_id") + url String + secret String + events String[] + description String? + status WebhookStatus @default(ACTIVE) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + deliveries WebhookDeliveryLog[] + + @@index([userId]) + @@index([status]) + @@map("webhooks") +} + +model WebhookDeliveryLog { + id String @id @default(uuid()) + webhookId String @map("webhook_id") + eventType String @map("event_type") + payload Json + status WebhookDeliveryStatus @default(PENDING) + responseCode Int? @map("response_code") + responseBody String? @map("response_body") @db.Text + attempts Int @default(0) + maxAttempts Int @default(5) + nextRetryAt DateTime? @map("next_retry_at") + error String? @db.Text + deliveredAt DateTime? @map("delivered_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) + + @@index([webhookId, status]) + @@index([status, nextRetryAt]) + @@index([eventType]) + @@map("webhook_delivery_logs") +} + +// ─── Property Tour Scheduling (#957) ────────────────────────────────────────── + +enum TourRequestStatus { + PENDING + CONFIRMED + CANCELLED + COMPLETED + DECLINED +} + +enum TourType { + PRIVATE + OPEN_HOUSE +} + +model TourRequest { + id String @id @default(uuid()) + propertyId String @map("property_id") + requesterId String @map("requester_id") + agentId String? @map("agent_id") + tourType TourType @default(PRIVATE) @map("tour_type") + status TourRequestStatus @default(PENDING) + requestedAt DateTime @map("requested_at") + confirmedAt DateTime? @map("confirmed_at") + notes String? @db.Text + timezone String @default("UTC") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) + requester User @relation("TourRequestRequester", fields: [requesterId], references: [id], onDelete: Cascade) + agent User? @relation("TourRequestAgent", fields: [agentId], references: [id], onDelete: SetNull) + + @@index([propertyId]) + @@index([requesterId]) + @@index([agentId]) + @@index([status]) + @@index([requestedAt]) + @@map("tour_requests") +} + +model AgentAvailability { + id String @id @default(uuid()) + agentId String @map("agent_id") + dayOfWeek Int @map("day_of_week") // 0=Sun, 6=Sat + startTime String @map("start_time") // "HH:MM" + endTime String @map("end_time") // "HH:MM" + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + agent User @relation(fields: [agentId], references: [id], onDelete: Cascade) + + @@index([agentId, dayOfWeek]) + @@map("agent_availabilities") +} + +// ─── Support Tickets with SLAs (#955) ──────────────────────────────────────── + +enum TicketCategory { + GENERAL + BILLING + TECHNICAL + PROPERTY_LISTING + TRANSACTION + ACCOUNT + FRAUD_REPORT + OTHER +} + +enum TicketPriority { + LOW + MEDIUM + HIGH + CRITICAL +} + +enum TicketStatus { + NEW + IN_PROGRESS + WAITING_ON_CUSTOMER + RESOLVED + CLOSED +} + +model SupportTicket { + id String @id @default(uuid()) + userId String @map("user_id") + assignedToId String? @map("assigned_to_id") + category TicketCategory @default(GENERAL) + priority TicketPriority @default(MEDIUM) + status TicketStatus @default(NEW) + subject String + description String @db.Text + transactionId String? @map("transaction_id") + propertyId String? @map("property_id") + slaDeadline DateTime? @map("sla_deadline") + slaBreached Boolean @default(false) @map("sla_breached") + firstResponseAt DateTime? @map("first_response_at") + resolvedAt DateTime? @map("resolved_at") + closedAt DateTime? @map("closed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User? @relation("SupportTicketUser", fields: [userId], references: [id], onDelete: Cascade) + assignedTo User? @relation("SupportTicketAgent", fields: [assignedToId], references: [id], onDelete: SetNull) + notes SupportTicketNote[] + + @@index([userId]) + @@index([assignedToId]) + @@index([status]) + @@index([priority, status]) + @@index([slaDeadline, slaBreached]) + @@map("support_tickets") +} + +model SupportTicketNote { + id String @id @default(uuid()) + ticketId String @map("ticket_id") + authorId String @map("author_id") + content String @db.Text + isPublic Boolean @default(false) @map("is_public") + createdAt DateTime @default(now()) @map("created_at") + + ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + + @@index([ticketId, createdAt]) + @@map("support_ticket_notes") +} + +// ─── User relation additions ────────────────────────────────────────────────── + +// Note: Webhook, TourRequest, AgentAvailability, SupportTicket relations +// are defined on the User model through @relation directives above. +// Additional User relations needed: \ No newline at end of file diff --git a/src/blockchain/blockchain.controller.ts b/src/blockchain/blockchain.controller.ts index db91ba36..1c8b87a9 100644 --- a/src/blockchain/blockchain.controller.ts +++ b/src/blockchain/blockchain.controller.ts @@ -140,4 +140,37 @@ export class BlockchainController { getStatus(): Record { return this.blockchainService.getStatus(); } + + // ─── RPC Health Monitoring Endpoints ────────────────────────────────────── + + @Get('rpc/health') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get RPC provider health status', + description: 'Check health of all configured RPC providers with latency and block info', + }) + async getRpcHealth() { + return this.blockchainService.getRpcHealthSummary(); + } + + @Post('rpc/health/check') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Trigger RPC health check', + description: 'Manually trigger an RPC health check across all providers', + }) + async triggerRpcHealthCheck() { + await this.blockchainService.checkRpcHealth(); + return this.blockchainService.getRpcHealthSummary(); + } + + @Get('rpc/gas-price') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get current gas price', + description: 'Fetch current gas price estimates from the active RPC provider', + }) + async getGasPrice() { + return this.blockchainService.getGasPrice(); + } } diff --git a/src/blockchain/blockchain.module.ts b/src/blockchain/blockchain.module.ts index 1cfdab8e..3fd5a58e 100644 --- a/src/blockchain/blockchain.module.ts +++ b/src/blockchain/blockchain.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { BlockchainService } from './blockchain.service'; import { BlockchainController } from './blockchain.controller'; import { PrismaModule } from '../database/prisma.module'; @Module({ - imports: [ConfigModule, PrismaModule], + imports: [ConfigModule, PrismaModule, ScheduleModule.forRoot()], providers: [BlockchainService], controllers: [BlockchainController], exports: [BlockchainService], diff --git a/src/blockchain/blockchain.service.ts b/src/blockchain/blockchain.service.ts index c063e241..d5434e43 100644 --- a/src/blockchain/blockchain.service.ts +++ b/src/blockchain/blockchain.service.ts @@ -7,6 +7,7 @@ import { InternalServerErrorException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; import * as crypto from 'crypto'; import { PrismaService } from '../database/prisma.service'; import { TransactionStatus } from '../types/prisma.types'; @@ -18,6 +19,12 @@ import { BlockchainNetwork, GetBlockchainStatsDto, } from './dto/blockchain.dto'; +import { + RpcHealthCheckResult, + RpcProviderStatus, + GasPriceResult, + RpcHealthSummaryDto, +} from './dto/rpc-health.dto'; import { BlockchainErrorClassifier, BlockchainErrorType } from './errors/blockchain-error'; interface BlockchainConfig { @@ -40,6 +47,17 @@ interface BlockchainTransaction { createdAt: Date; } +interface RpcProviderState { + url: string; + isActive: boolean; + isHealthy: boolean; + lastCheckAt: Date | null; + latencyMs: number | null; + consecutiveFailures: number; + lastBlockNumber: number | null; + lastGasPrice: string | null; +} + @Injectable() export class BlockchainService { private readonly logger = new Logger(BlockchainService.name); @@ -48,6 +66,14 @@ export class BlockchainService { private contract: any; private transactionCache = new Map(); + // ─── RPC Health Monitoring & Provider Failover ──────────────────────────── + private rpcProviders: RpcProviderState[] = []; + private activeRpcIndex = 0; + private lastHealthCheck: Date | null = null; + private readonly HEALTH_CHECK_INTERVAL_MS = 30_000; + private readonly MAX_CONSECUTIVE_FAILURES = 3; + private readonly RPC_TIMEOUT_MS = 10_000; + // Only COMPLETED transactions are allowed to be recorded on the blockchain private static readonly ALLOWED_TRANSACTION_STATUSES: TransactionStatus[] = [ TransactionStatus.COMPLETED, @@ -67,6 +93,202 @@ export class BlockchainService { private prisma: PrismaService, ) { this.initializeConfig(); + this.initializeRpcProviders(); + } + + // ─── RPC Health Monitoring & Failover Methods ──────────────────────────── + + private initializeRpcProviders() { + const primaryRpc = this.config?.rpcUrl; + const additionalRpc = this.configService.get('BLOCKCHAIN_RPC_URLS', '') + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); + + const urls = [primaryRpc, ...additionalRpc].filter(Boolean); + this.rpcProviders = urls.map((url) => ({ + url, + isActive: true, + isHealthy: true, + lastCheckAt: null, + latencyMs: null, + consecutiveFailures: 0, + lastBlockNumber: null, + lastGasPrice: null, + })); + + if (this.rpcProviders.length === 0) { + this.logger.warn('No RPC providers configured'); + } else { + this.logger.log(`Initialized ${this.rpcProviders.length} RPC provider(s)`); + } + } + + @Cron(CronExpression.EVERY_30_SECONDS) + async checkRpcHealth() { + if (!this.config?.enabled || this.rpcProviders.length === 0) return; + + const checks = this.rpcProviders.map((provider) => this.checkSingleRpcHealth(provider)); + await Promise.allSettled(checks); + + this.lastHealthCheck = new Date(); + + // Failover: if active provider is unhealthy, switch to next healthy one + const activeProvider = this.rpcProviders[this.activeRpcIndex]; + if (!activeProvider?.isHealthy) { + const nextHealthy = this.rpcProviders.findIndex( + (p, i) => i !== this.activeRpcIndex && p.isHealthy && p.isActive, + ); + if (nextHealthy !== -1) { + this.logger.warn( + `Failing over RPC from ${activeProvider?.url} to ${this.rpcProviders[nextHealthy].url}`, + ); + this.activeRpcIndex = nextHealthy; + } else { + this.logger.error('All RPC providers are unhealthy - service degraded'); + } + } + } + + private async checkSingleRpcHealth(provider: RpcProviderState): Promise { + const start = Date.now(); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.RPC_TIMEOUT_MS); + + const response = await fetch(provider.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + id: 1, + }), + signal: controller.signal, + }); + + clearTimeout(timeout); + const latencyMs = Date.now() - start; + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const blockNumber = data.result ? parseInt(data.result, 16) : null; + + // Get gas price + let gasPrice: string | null = null; + try { + const gpController = new AbortController(); + const gpTimeout = setTimeout(() => gpController.abort(), this.RPC_TIMEOUT_MS); + const gpResponse = await fetch(provider.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + id: 2, + }), + signal: gpController.signal, + }); + clearTimeout(gpTimeout); + if (gpResponse.ok) { + const gpData = await gpResponse.json(); + if (gpData.result) { + gasPrice = (parseInt(gpData.result, 16) / 1e9).toFixed(2) + ' gwei'; + } + } + } catch { + // Gas price fetch is non-critical + } + + provider.isHealthy = true; + provider.lastCheckAt = new Date(); + provider.latencyMs = latencyMs; + provider.consecutiveFailures = 0; + provider.lastBlockNumber = blockNumber; + provider.lastGasPrice = gasPrice; + } catch (error) { + provider.consecutiveFailures++; + provider.isHealthy = provider.consecutiveFailures < this.MAX_CONSECUTIVE_FAILURES; + provider.lastCheckAt = new Date(); + provider.latencyMs = null; + this.logger.warn( + `RPC health check failed for ${provider.url}: ${error.message} (failures: ${provider.consecutiveFailures})`, + ); + } + } + + async getRpcHealthSummary(): Promise { + return { + providers: this.rpcProviders.map((p) => ({ + url: p.url, + isActive: p.isActive, + isHealthy: p.isHealthy, + lastCheckAt: p.lastCheckAt, + latencyMs: p.latencyMs, + consecutiveFailures: p.consecutiveFailures, + })), + activeProvider: this.rpcProviders[this.activeRpcIndex]?.url || '', + lastCheckAt: this.lastHealthCheck, + network: this.config?.network || BlockchainNetwork.SEPOLIA, + }; + } + + async getGasPrice(): Promise { + const provider = this.rpcProviders[this.activeRpcIndex]; + if (!provider?.isHealthy) { + // Return graceful fallback + return { + low: 'N/A', + medium: 'N/A', + high: 'N/A', + timestamp: new Date(), + }; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.RPC_TIMEOUT_MS); + const response = await fetch(provider.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + id: 1, + }), + signal: controller.signal, + }); + clearTimeout(timeout); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const basePrice = data.result ? parseInt(data.result, 16) / 1e9 : 0; + + return { + low: (basePrice * 0.8).toFixed(2) + ' gwei', + medium: basePrice.toFixed(2) + ' gwei', + high: (basePrice * 1.5).toFixed(2) + ' gwei', + timestamp: new Date(), + }; + } catch (error) { + this.logger.warn(`Gas price fetch failed: ${error.message}`); + return { low: 'N/A', medium: 'N/A', high: 'N/A', timestamp: new Date() }; + } + } + + private getActiveRpcUrl(): string { + const provider = this.rpcProviders[this.activeRpcIndex]; + if (!provider?.isHealthy) { + // Graceful degradation: use config URL as last resort + return this.config?.rpcUrl || ''; + } + return provider.url; } /** diff --git a/src/blockchain/dto/rpc-health.dto.ts b/src/blockchain/dto/rpc-health.dto.ts new file mode 100644 index 00000000..37b651c3 --- /dev/null +++ b/src/blockchain/dto/rpc-health.dto.ts @@ -0,0 +1,50 @@ +// @ts-nocheck + +import { IsArray, IsOptional, IsString } from 'class-validator'; + +export class RpcHealthCheckResult { + url: string; + isHealthy: boolean; + latencyMs: number; + latestBlockNumber: number | null; + gasPrice: string | null; + error: string | null; + checkedAt: Date; +} + +export class RpcProviderStatus { + url: string; + isActive: boolean; + isHealthy: boolean; + lastCheckAt: Date | null; + latencyMs: number | null; + consecutiveFailures: number; +} + +export class GasPriceResult { + low: string; + medium: string; + high: string; + timestamp: Date; +} + +export class RpcHealthSummaryDto { + providers: RpcProviderStatus[]; + activeProvider: string; + lastCheckAt: Date | null; + network: string; +} + +export class AddRpcProviderDto { + @IsString() + url: string; + + @IsOptional() + @IsString() + name?: string; +} + +export class RemoveRpcProviderDto { + @IsString() + url: string; +} diff --git a/src/open-house/dto/tour-request.dto.ts b/src/open-house/dto/tour-request.dto.ts new file mode 100644 index 00000000..b07ac983 --- /dev/null +++ b/src/open-house/dto/tour-request.dto.ts @@ -0,0 +1,71 @@ +// @ts-nocheck + +import { + IsDateString, + IsEnum, + IsOptional, + IsString, + IsInt, + Matches, + IsBoolean, +} from 'class-validator'; +import { TourType } from '../open-house.service'; + +export class CreateTourRequestDto { + @IsString() + propertyId: string; + + @IsOptional() + @IsString() + agentId?: string; + + @IsOptional() + @IsEnum(TourType) + tourType?: TourType; + + @IsDateString() + requestedAt: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + timezone?: string; +} + +export class UpdateTourRequestStatusDto { + @IsEnum(['CONFIRMED', 'CANCELLED', 'COMPLETED', 'DECLINED']) + status: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class CreateAgentAvailabilityDto { + @IsInt() + dayOfWeek: number; + + @IsString() + @Matches(/^\d{2}:\d{2}$/) + startTime: string; + + @IsString() + @Matches(/^\d{2}:\d{2}$/) + endTime: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class AgentAvailabilityResponseDto { + id: string; + agentId: string; + dayOfWeek: number; + startTime: string; + endTime: string; + isActive: boolean; +} diff --git a/src/open-house/open-house.controller.ts b/src/open-house/open-house.controller.ts index f0f6d46b..1573ebf9 100644 --- a/src/open-house/open-house.controller.ts +++ b/src/open-house/open-house.controller.ts @@ -1,10 +1,18 @@ // @ts-nocheck -import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Header, Param, Patch, Post, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; import { OpenHouseService } from './open-house.service'; import { CreateOpenHouseDto } from './dto/create-open-house.dto'; import { RsvpOpenHouseDto } from './dto/rsvp-open-house.dto'; +import { + CreateTourRequestDto, + UpdateTourRequestStatusDto, + CreateAgentAvailabilityDto, +} from './dto/tour-request.dto'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; @ApiTags('open-house') @Controller('open-house') @@ -34,4 +42,74 @@ export class OpenHouseController { rsvp(@Param('id') id: string, @Body() dto: RsvpOpenHouseDto) { return this.openHouseService.rsvp(id, dto); } + + // ─── Tour Request Endpoints ────────────────────────────────────────────── + + @UseGuards(JwtAuthGuard) + @Post('tours') + @ApiOperation({ summary: 'Request a private property tour' }) + createTourRequest(@CurrentUser() user: any, @Body() dto: CreateTourRequestDto) { + return this.openHouseService.createTourRequest(user.id, dto); + } + + @Get('tours/:id') + @ApiOperation({ summary: 'Get tour request details' }) + getTourRequest(@Param('id') id: string) { + return this.openHouseService.getTourRequest(id); + } + + @UseGuards(JwtAuthGuard) + @Patch('tours/:id/status') + @ApiOperation({ summary: 'Update tour request status (confirm/cancel/complete/decline)' }) + updateTourStatus(@Param('id') id: string, @Body() dto: UpdateTourRequestStatusDto) { + return this.openHouseService.updateTourStatus(id, dto); + } + + @UseGuards(JwtAuthGuard) + @Get('tours/my/list') + @ApiOperation({ summary: 'List my tour requests' }) + getMyTourRequests(@CurrentUser() user: any) { + return this.openHouseService.getMyTourRequests(user.id); + } + + @UseGuards(JwtAuthGuard) + @Get('tours/agent/list') + @ApiOperation({ summary: 'List tour requests assigned to agent' }) + getAgentTourRequests(@CurrentUser() user: any) { + return this.openHouseService.getAgentTourRequests(user.id); + } + + // ─── Agent Availability ────────────────────────────────────────────────── + + @UseGuards(JwtAuthGuard) + @Post('availability') + @ApiOperation({ summary: 'Set agent availability for tours' }) + setAvailability(@CurrentUser() user: any, @Body() dto: CreateAgentAvailabilityDto) { + return this.openHouseService.setAgentAvailability(user.id, dto); + } + + @Get('availability/:agentId') + @ApiOperation({ summary: 'Get agent availability' }) + getAvailability(@Param('agentId') agentId: string) { + return this.openHouseService.getAgentAvailability(agentId); + } + + @UseGuards(JwtAuthGuard) + @Delete('availability/:id') + @ApiOperation({ summary: 'Remove agent availability slot' }) + removeAvailability(@Param('id') id: string) { + return this.openHouseService.removeAgentAvailability(id); + } + + // ─── iCal Export ───────────────────────────────────────────────────────── + + @UseGuards(JwtAuthGuard) + @Get('tours/calendar/export') + @Header('Content-Type', 'text/calendar; charset=utf-8') + @ApiOperation({ summary: 'Export tours as iCal file' }) + async exportCalendar(@CurrentUser() user: any, @Res() res: Response) { + const ical = await this.openHouseService.exportTourICal(user.id); + res.setHeader('Content-Disposition', 'attachment; filename="tours.ics"'); + res.send(ical); + } } diff --git a/src/open-house/open-house.module.ts b/src/open-house/open-house.module.ts index d280dc1e..160de8fe 100644 --- a/src/open-house/open-house.module.ts +++ b/src/open-house/open-house.module.ts @@ -5,9 +5,10 @@ import { OpenHouseController } from './open-house.controller'; import { OpenHouseService } from './open-house.service'; import { PrismaModule } from '../database/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ - imports: [PrismaModule, NotificationsModule], + imports: [PrismaModule, NotificationsModule, ScheduleModule.forRoot()], controllers: [OpenHouseController], providers: [OpenHouseService], exports: [OpenHouseService], diff --git a/src/open-house/open-house.service.ts b/src/open-house/open-house.service.ts index 8279b3b8..303c6ae3 100644 --- a/src/open-house/open-house.service.ts +++ b/src/open-house/open-house.service.ts @@ -1,13 +1,21 @@ // @ts-nocheck -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; import { CreateOpenHouseDto } from './dto/create-open-house.dto'; import { RsvpOpenHouseDto } from './dto/rsvp-open-house.dto'; +import { + CreateTourRequestDto, + UpdateTourRequestStatusDto, + CreateAgentAvailabilityDto, +} from './dto/tour-request.dto'; import { NotificationsService } from '../notifications/notifications.service'; +import { Cron, CronExpression } from '@nestjs/schedule'; @Injectable() export class OpenHouseService { + private readonly logger = new Logger(OpenHouseService.name); + constructor( private readonly prisma: PrismaService, private readonly notificationsService: NotificationsService, @@ -81,4 +89,237 @@ export class OpenHouseService { return rsvp; } + + // ─── Private Tour Requests ──────────────────────────────────────────────── + + async createTourRequest(userId: string, dto: CreateTourRequestDto) { + const property = await this.prisma.property.findUnique({ + where: { id: dto.propertyId }, + }); + if (!property) throw new NotFoundException('Property not found'); + + const tourRequest = await this.prisma.tourRequest.create({ + data: { + propertyId: dto.propertyId, + requesterId: userId, + agentId: dto.agentId, + tourType: (dto.tourType || 'PRIVATE') as any, + requestedAt: new Date(dto.requestedAt), + notes: dto.notes, + timezone: dto.timezone || 'UTC', + }, + include: { property: true, requester: { select: { id: true, firstName: true, lastName: true, email: true } } }, + }); + + if (dto.agentId) { + await this.notificationsService.sendNotification( + dto.agentId, + 'New Tour Request', + `You have a new tour request for "${property.title}" at ${property.address}.`, + 'TOUR_REQUEST', + { tourRequestId: tourRequest.id, propertyId: dto.propertyId }, + ); + } + + return tourRequest; + } + + async getTourRequest(id: string) { + const tour = await this.prisma.tourRequest.findUnique({ + where: { id }, + include: { property: true, requester: { select: { id: true, firstName: true, lastName: true } }, agent: { select: { id: true, firstName: true, lastName: true } } }, + }); + if (!tour) throw new NotFoundException('Tour request not found'); + return tour; + } + + async updateTourStatus(id: string, dto: UpdateTourRequestStatusDto) { + const tour = await this.prisma.tourRequest.findUnique({ where: { id } }); + if (!tour) throw new NotFoundException('Tour request not found'); + + const data: any = { status: dto.status }; + if (dto.status === 'CONFIRMED') data.confirmedAt = new Date(); + + const updated = await this.prisma.tourRequest.update({ + where: { id }, + data, + include: { property: true }, + }); + + await this.notificationsService.sendNotification( + tour.requesterId, + `Tour ${dto.status.toLowerCase()}`, + `Your tour request for "${updated.property.title}" has been ${dto.status.toLowerCase()}.`, + 'TOUR_STATUS_UPDATE', + { tourRequestId: id, status: dto.status }, + ); + + return updated; + } + + async getMyTourRequests(userId: string) { + return this.prisma.tourRequest.findMany({ + where: { requesterId: userId }, + include: { property: { select: { id: true, title: true, address: true } } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async getAgentTourRequests(agentId: string) { + return this.prisma.tourRequest.findMany({ + where: { agentId }, + include: { property: { select: { id: true, title: true, address: true } }, requester: { select: { id: true, firstName: true, lastName: true } } }, + orderBy: { requestedAt: 'asc' }, + }); + } + + // ─── Agent Availability ─────────────────────────────────────────────────── + + async setAgentAvailability(agentId: string, dto: CreateAgentAvailabilityDto) { + const existing = await this.prisma.agentAvailability.findFirst({ + where: { agentId, dayOfWeek: dto.dayOfWeek, startTime: dto.startTime }, + }); + + if (existing) { + return this.prisma.agentAvailability.update({ + where: { id: existing.id }, + data: { + endTime: dto.endTime, + isActive: dto.isActive ?? true, + }, + }); + } + + return this.prisma.agentAvailability.create({ + data: { + agentId, + dayOfWeek: dto.dayOfWeek, + startTime: dto.startTime, + endTime: dto.endTime, + isActive: dto.isActive ?? true, + }, + }); + } + + async getAgentAvailability(agentId: string) { + return this.prisma.agentAvailability.findMany({ + where: { agentId, isActive: true }, + orderBy: { dayOfWeek: 'asc' }, + }); + } + + async removeAgentAvailability(id: string) { + await this.prisma.agentAvailability.delete({ where: { id } }); + return { deleted: true }; + } + + // ─── iCal Export ────────────────────────────────────────────────────────── + + async exportTourICal(userId: string) { + const tours = await this.prisma.tourRequest.findMany({ + where: { + OR: [{ requesterId: userId }, { agentId: userId }], + status: { in: ['PENDING', 'CONFIRMED'] }, + }, + include: { property: true }, + }); + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//PropChain//Tour Scheduling//EN', + 'CALSCALE:GREGORIAN', + ]; + + for (const tour of tours) { + const dtStart = tour.requestedAt.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + const dtEnd = new Date(tour.requestedAt.getTime() + 60 * 60 * 1000).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + lines.push( + 'BEGIN:VEVENT', + `UID:${tour.id}@propchain`, + `DTSTART:${dtStart}`, + `DTEND:${dtEnd}`, + `SUMMARY:Property Tour - ${tour.property.title}`, + `LOCATION:${tour.property.address}`, + `DESCRIPTION:${tour.notes || 'Property tour'}`, + `STATUS:${tour.status}`, + 'END:VEVENT', + ); + } + + lines.push('END:VCALENDAR'); + return lines.join('\r\n'); + } + + // ─── Tour Reminders ─────────────────────────────────────────────────────── + + @Cron('0 * * * *') + async send24hTourReminders() { + const now = new Date(); + const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const windowStart = new Date(in24h.getTime() - 30 * 60 * 1000); + const windowEnd = new Date(in24h.getTime() + 30 * 60 * 1000); + + const tours = await this.prisma.tourRequest.findMany({ + where: { + status: 'CONFIRMED', + requestedAt: { gte: windowStart, lte: windowEnd }, + }, + include: { property: true }, + }); + + for (const tour of tours) { + await this.notificationsService.sendNotification( + tour.requesterId, + 'Tour Reminder (24h)', + `Reminder: Your tour for "${tour.property.title}" is tomorrow.`, + 'TOUR_REMINDER_24H', + { tourRequestId: tour.id }, + ); + if (tour.agentId) { + await this.notificationsService.sendNotification( + tour.agentId, + 'Tour Reminder (24h)', + `Reminder: You have a tour for "${tour.property.title}" tomorrow.`, + 'TOUR_REMINDER_24H', + { tourRequestId: tour.id }, + ); + } + } + } + + @Cron('0 * * * *') + async send1hTourReminders() { + const now = new Date(); + const in1h = new Date(now.getTime() + 60 * 60 * 1000); + const windowStart = new Date(in1h.getTime() - 15 * 60 * 1000); + const windowEnd = new Date(in1h.getTime() + 15 * 60 * 1000); + + const tours = await this.prisma.tourRequest.findMany({ + where: { + status: 'CONFIRMED', + requestedAt: { gte: windowStart, lte: windowEnd }, + }, + include: { property: true }, + }); + + for (const tour of tours) { + await this.notificationsService.sendNotification( + tour.requesterId, + 'Tour Reminder (1h)', + `Reminder: Your tour for "${tour.property.title}" is in 1 hour!`, + 'TOUR_REMINDER_1H', + { tourRequestId: tour.id }, + ); + if (tour.agentId) { + await this.notificationsService.sendNotification( + tour.agentId, + 'Tour Reminder (1h)', + `Reminder: You have a tour for "${tour.property.title}" in 1 hour!`, + 'TOUR_REMINDER_1H', + { tourRequestId: tour.id }, + ); + } + } + } } diff --git a/src/support-tickets/dto/support-ticket.dto.ts b/src/support-tickets/dto/support-ticket.dto.ts new file mode 100644 index 00000000..c640f921 --- /dev/null +++ b/src/support-tickets/dto/support-ticket.dto.ts @@ -0,0 +1,110 @@ +// @ts-nocheck + +import { + IsEnum, + IsInt, + IsOptional, + IsString, + IsDateString, + MaxLength, + Min, + Max, +} from 'class-validator'; + +export class CreateSupportTicketDto { + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsString() + priority?: string; + + @IsString() + @MaxLength(200) + subject: string; + + @IsString() + description: string; + + @IsOptional() + @IsString() + transactionId?: string; + + @IsOptional() + @IsString() + propertyId?: string; +} + +export class UpdateTicketStatusDto { + @IsString() + status: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class AssignTicketDto { + @IsString() + agentId: string; +} + +export class AddTicketNoteDto { + @IsString() + content: string; + + @IsOptional() + isPublic?: boolean; +} + +export class ListTicketsDto { + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + priority?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} + +export class TicketSlaInfo { + deadline: Date | null; + breached: boolean; + timeRemaining: string | null; +} + +export class SupportTicketResponseDto { + id: string; + userId: string; + assignedToId: string | null; + category: string; + priority: string; + status: string; + subject: string; + description: string; + transactionId: string | null; + propertyId: string | null; + slaDeadline: Date | null; + slaBreached: boolean; + firstResponseAt: Date | null; + resolvedAt: Date | null; + closedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/support-tickets/support-tickets.controller.ts b/src/support-tickets/support-tickets.controller.ts index f2b1cd9f..1045e5f0 100644 --- a/src/support-tickets/support-tickets.controller.ts +++ b/src/support-tickets/support-tickets.controller.ts @@ -1,29 +1,103 @@ // @ts-nocheck -import { Body, Controller, Get, Param, Post, Req } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; import { SupportTicketsService } from './support-tickets.service'; +import { + CreateSupportTicketDto, + UpdateTicketStatusDto, + AssignTicketDto, + AddTicketNoteDto, +} from './dto/support-ticket.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +@UseGuards(JwtAuthGuard) @Controller('support-tickets') export class SupportTicketsController { constructor(private readonly supportTicketsService: SupportTicketsService) {} - @Post(':id/internal-notes') - addInternalNote(@Param('id') id: string, @Req() req: any, @Body('content') content: string) { - return this.supportTicketsService.addInternalNote(id, req.user?.id ?? 'system', content); + @Post() + createTicket(@CurrentUser() user: any, @Body() dto: CreateSupportTicketDto) { + return this.supportTicketsService.createTicket(user.id, dto); } - @Post(':id/public-replies') - addPublicReply(@Param('id') id: string, @Req() req: any, @Body('content') content: string) { - return this.supportTicketsService.addPublicReply(id, req.user?.id ?? 'system', content); + @Get() + listTickets( + @CurrentUser() user: any, + @Query('status') status?: string, + @Query('priority') priority?: string, + @Query('category') category?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.supportTicketsService.listTickets({ + status, + priority, + category, + page: page ? parseInt(page) : undefined, + limit: limit ? parseInt(limit) : undefined, + }); } - @Get(':id/agent-view') - listForAgent(@Param('id') id: string) { - return this.supportTicketsService.listForAgent(id); + @Get('my') + getMyTickets(@CurrentUser() user: any) { + return this.supportTicketsService.listTickets({ userId: user.id }); } - @Get(':id/user-view') - listPublicForUser(@Param('id') id: string) { - return this.supportTicketsService.listPublicForUser(id); + @Get('assigned') + getAssignedTickets(@CurrentUser() user: any) { + return this.supportTicketsService.listTickets({ assignedToId: user.id }); + } + + @Get('unassigned') + getUnassignedTickets() { + return this.supportTicketsService.getUnassignedTickets(); + } + + @Get('metrics') + getMetrics(@CurrentUser() user: any) { + return this.supportTicketsService.getTicketMetrics(user.id); + } + + @Get(':id') + getTicket(@Param('id') id: string) { + return this.supportTicketsService.getTicket(id); + } + + @Patch(':id/status') + updateStatus( + @Param('id') id: string, + @CurrentUser() user: any, + @Body() dto: UpdateTicketStatusDto, + ) { + return this.supportTicketsService.updateStatus(id, dto, user.id); + } + + @Patch(':id/assign') + assignTicket(@Param('id') id: string, @Body() dto: AssignTicketDto) { + return this.supportTicketsService.assignTicket(id, dto); + } + + @Post(':id/notes') + addNote( + @Param('id') id: string, + @CurrentUser() user: any, + @Body() dto: AddTicketNoteDto, + ) { + return this.supportTicketsService.addNote(id, dto, user.id); + } + + @Get(':id/sla') + getSlaInfo(@Param('id') id: string) { + return this.supportTicketsService.getSlaInfo(id); } } diff --git a/src/support-tickets/support-tickets.module.ts b/src/support-tickets/support-tickets.module.ts index 0e74e05e..51757eb6 100644 --- a/src/support-tickets/support-tickets.module.ts +++ b/src/support-tickets/support-tickets.module.ts @@ -4,10 +4,13 @@ import { Module } from '@nestjs/common'; import { SupportTicketsController } from './support-tickets.controller'; import { SupportTicketsService } from './support-tickets.service'; import { PrismaModule } from '../database/prisma.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, NotificationsModule, ScheduleModule.forRoot()], controllers: [SupportTicketsController], providers: [SupportTicketsService], + exports: [SupportTicketsService], }) export class SupportTicketsModule {} diff --git a/src/support-tickets/support-tickets.service.ts b/src/support-tickets/support-tickets.service.ts index 63e1c651..1249cfd1 100644 --- a/src/support-tickets/support-tickets.service.ts +++ b/src/support-tickets/support-tickets.service.ts @@ -1,44 +1,300 @@ // @ts-nocheck -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { + CreateSupportTicketDto, + UpdateTicketStatusDto, + AssignTicketDto, + AddTicketNoteDto, +} from './dto/support-ticket.dto'; + +const SLA_HOURS: Record = { + CRITICAL: 4, + HIGH: 8, + MEDIUM: 24, + LOW: 72, +}; + +const VALID_STATUS_TRANSITIONS: Record = { + NEW: ['IN_PROGRESS', 'CLOSED'], + IN_PROGRESS: ['WAITING_ON_CUSTOMER', 'RESOLVED', 'CLOSED'], + WAITING_ON_CUSTOMER: ['IN_PROGRESS', 'RESOLVED', 'CLOSED'], + RESOLVED: ['CLOSED', 'IN_PROGRESS'], + CLOSED: ['IN_PROGRESS'], +}; @Injectable() export class SupportTicketsService { - constructor(private readonly prisma: PrismaService) {} + private readonly logger = new Logger(SupportTicketsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notificationsService: NotificationsService, + ) {} + + async createTicket(userId: string, dto: CreateSupportTicketDto) { + const priority = dto.priority || 'MEDIUM'; + const slaHours = SLA_HOURS[priority] || SLA_HOURS.MEDIUM; + const slaDeadline = new Date(Date.now() + slaHours * 60 * 60 * 1000); + + const ticket = await this.prisma.supportTicket.create({ + data: { + userId, + category: (dto.category || 'GENERAL') as any, + priority: priority as any, + subject: dto.subject, + description: dto.description, + transactionId: dto.transactionId, + propertyId: dto.propertyId, + slaDeadline, + }, + }); - private async assertTicketExists(ticketId: string) { - const tx = await this.prisma.transaction.findUnique({ where: { id: ticketId } }); - if (!tx) throw new NotFoundException('Support ticket not found'); + this.logger.log(`Support ticket created: ${ticket.id} (priority: ${priority}, SLA: ${slaDeadline.toISOString()})`); + return ticket; } - async addInternalNote(ticketId: string, authorId: string, content: string) { - await this.assertTicketExists(ticketId); - return this.prisma.transactionNote.create({ - data: { transactionId: ticketId, authorId, content, isPublic: false }, + async getTicket(id: string) { + const ticket = await this.prisma.supportTicket.findUnique({ + where: { id }, + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + assignedTo: { select: { id: true, firstName: true, lastName: true, email: true } }, + notes: { + orderBy: { createdAt: 'asc' }, + include: { author: { select: { id: true, firstName: true, lastName: true } } }, + }, + }, + }); + if (!ticket) throw new NotFoundException('Support ticket not found'); + return ticket; + } + + async updateStatus(id: string, dto: UpdateTicketStatusDto, actorId?: string) { + const ticket = await this.prisma.supportTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Support ticket not found'); + + const allowed = VALID_STATUS_TRANSITIONS[ticket.status] || []; + if (!allowed.includes(dto.status)) { + throw new BadRequestException( + `Cannot transition from ${ticket.status} to ${dto.status}. Allowed: ${allowed.join(', ')}`, + ); + } + + const data: any = { status: dto.status as any }; + if (dto.status === 'RESOLVED') data.resolvedAt = new Date(); + if (dto.status === 'CLOSED') data.closedAt = new Date(); + if (dto.status === 'IN_PROGRESS' && !ticket.firstResponseAt) { + data.firstResponseAt = new Date(); + } + + const updated = await this.prisma.supportTicket.update({ + where: { id }, + data, + include: { user: { select: { id: true, firstName: true, email: true } } }, }); + + // Notify user of status change + if (updated.userId) { + await this.notificationsService.sendNotification( + updated.userId, + `Ticket Status Updated`, + `Your ticket "${updated.subject}" is now ${dto.status}.`, + 'SUPPORT_TICKET_UPDATE', + { ticketId: id, status: dto.status }, + ); + } + + if (actorId && dto.notes) { + await this.addNote(id, { content: dto.notes, isPublic: true }, actorId); + } + + return updated; } - async addPublicReply(ticketId: string, authorId: string, content: string) { - await this.assertTicketExists(ticketId); - return this.prisma.transactionNote.create({ - data: { transactionId: ticketId, authorId, content, isPublic: true }, + async assignTicket(id: string, dto: AssignTicketDto) { + const ticket = await this.prisma.supportTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Support ticket not found'); + + const updated = await this.prisma.supportTicket.update({ + where: { id }, + data: { + assignedToId: dto.agentId, + status: ticket.status === 'NEW' ? 'IN_PROGRESS' : ticket.status, + ...(ticket.status === 'NEW' && { firstResponseAt: new Date() }), + }, + include: { + assignedTo: { select: { id: true, firstName: true, lastName: true } }, + }, }); + + await this.notificationsService.sendNotification( + dto.agentId, + 'Ticket Assigned', + `You have been assigned ticket "${ticket.subject}".`, + 'SUPPORT_TICKET_ASSIGNED', + { ticketId: id, priority: ticket.priority }, + ); + + return updated; } - async listForAgent(ticketId: string) { - await this.assertTicketExists(ticketId); - return this.prisma.transactionNote.findMany({ - where: { transactionId: ticketId }, - orderBy: { createdAt: 'asc' }, + async addNote(id: string, dto: AddTicketNoteDto, authorId: string) { + const ticket = await this.prisma.supportTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Support ticket not found'); + + // Update first response time if not yet set + if (!ticket.firstResponseAt) { + await this.prisma.supportTicket.update({ + where: { id }, + data: { firstResponseAt: new Date() }, + }); + } + + return this.prisma.supportTicketNote.create({ + data: { + ticketId: id, + authorId, + content: dto.content, + isPublic: dto.isPublic ?? true, + }, + include: { author: { select: { id: true, firstName: true, lastName: true } } }, }); } - async listPublicForUser(ticketId: string) { - await this.assertTicketExists(ticketId); - return this.prisma.transactionNote.findMany({ - where: { transactionId: ticketId, isPublic: true }, + async listTickets(filters: { + userId?: string; + assignedToId?: string; + status?: string; + priority?: string; + category?: string; + page?: number; + limit?: number; + }) { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const where: any = {}; + if (filters.userId) where.userId = filters.userId; + if (filters.assignedToId) where.assignedToId = filters.assignedToId; + if (filters.status) where.status = filters.status; + if (filters.priority) where.priority = filters.priority; + if (filters.category) where.category = filters.category; + + const [tickets, total] = await Promise.all([ + this.prisma.supportTicket.findMany({ + where, + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + assignedTo: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: [ + { priority: 'asc' }, + { createdAt: 'desc' }, + ], + skip, + take: limit, + }), + this.prisma.supportTicket.count({ where }), + ]); + + return { + tickets, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getUnassignedTickets() { + return this.prisma.supportTicket.findMany({ + where: { assignedToId: null, status: 'NEW' }, + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + }, orderBy: { createdAt: 'asc' }, }); } + + async getTicketMetrics(agentId?: string) { + const where: any = {}; + if (agentId) where.assignedToId = agentId; + + const [total, open, resolved, breached] = await Promise.all([ + this.prisma.supportTicket.count({ where }), + this.prisma.supportTicket.count({ + where: { ...where, status: { in: ['NEW', 'IN_PROGRESS', 'WAITING_ON_CUSTOMER'] } }, + }), + this.prisma.supportTicket.count({ + where: { ...where, status: { in: ['RESOLVED', 'CLOSED'] } }, + }), + this.prisma.supportTicket.count({ + where: { ...where, slaBreached: true }, + }), + ]); + + return { total, open, resolved, breached }; + } + + // ─── SLA Monitoring ────────────────────────────────────────────────────── + + @Cron(CronExpression.EVERY_5_MINUTES) + async checkSlaBreaches() { + const now = new Date(); + const breachedTickets = await this.prisma.supportTicket.findMany({ + where: { + slaBreached: false, + status: { in: ['NEW', 'IN_PROGRESS', 'WAITING_ON_CUSTOMER'] }, + slaDeadline: { lt: now }, + }, + include: { user: { select: { id: true, firstName: true, email: true } } }, + }); + + for (const ticket of breachedTickets) { + await this.prisma.supportTicket.update({ + where: { id: ticket.id }, + data: { slaBreached: true }, + }); + + this.logger.warn(`SLA breached for ticket ${ticket.id} (priority: ${ticket.priority})`); + + // Notify assigned agent + if (ticket.assignedToId) { + await this.notificationsService.sendNotification( + ticket.assignedToId, + 'SLA Breach Alert', + `Ticket "${ticket.subject}" has breached its SLA deadline.`, + 'SUPPORT_TICKET_SLA_BREACH', + { ticketId: ticket.id, priority: ticket.priority, slaDeadline: ticket.slaDeadline }, + ); + } + } + } + + async getSlaInfo(ticketId: string) { + const ticket = await this.prisma.supportTicket.findUnique({ where: { id: ticketId } }); + if (!ticket) throw new NotFoundException('Support ticket not found'); + + if (!ticket.slaDeadline) { + return { deadline: null, breached: ticket.slaBreached, timeRemaining: null }; + } + + const now = new Date(); + const diffMs = ticket.slaDeadline.getTime() - now.getTime(); + const breached = diffMs < 0 || ticket.slaBreached; + + let timeRemaining: string | null = null; + if (!breached) { + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + timeRemaining = `${hours}h ${minutes}m`; + } + + return { deadline: ticket.slaDeadline, breached, timeRemaining }; + } } diff --git a/src/webhooks/webhook.dto.ts b/src/webhooks/webhook.dto.ts index ae969fb2..85088d4b 100644 --- a/src/webhooks/webhook.dto.ts +++ b/src/webhooks/webhook.dto.ts @@ -1,6 +1,16 @@ // @ts-nocheck -import { IsArray, IsBoolean, IsEnum, IsOptional, IsString, IsUrl } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsEnum, + IsOptional, + IsString, + IsUrl, + IsEmail, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; export enum WebhookEventType { PROPERTY_CREATED = 'PROPERTY_CREATED', @@ -43,3 +53,26 @@ export class UpdateWebhookDto { @IsString() description?: string; } + +export class VerifyWebhookDto { + @IsString() + challenge: string; +} + +export class WebhookChallengeResponse { + challenge: string; +} + +export class WebhookDeliveryLogDto { + id: string; + webhookId: string; + eventType: string; + payload: any; + status: string; + responseCode: number | null; + attempts: number; + maxAttempts: number; + error: string | null; + deliveredAt: Date | null; + createdAt: Date; +} diff --git a/src/webhooks/webhooks.controller.ts b/src/webhooks/webhooks.controller.ts index de5ea731..1ddf675e 100644 --- a/src/webhooks/webhooks.controller.ts +++ b/src/webhooks/webhooks.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { WebhooksService } from './webhooks.service'; -import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto'; +import { CreateWebhookDto, UpdateWebhookDto, VerifyWebhookDto } from './webhook.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @@ -40,4 +40,9 @@ export class WebhooksController { getDeliveries(@Param('id') id: string, @CurrentUser() user: any) { return this.webhooksService.getDeliveries(id, user.id); } + + @Post(':id/verify') + verifyChallenge(@Param('id') id: string, @CurrentUser() user: any, @Body() dto: VerifyWebhookDto) { + return this.webhooksService.verifyChallenge(id, user.id, dto.challenge); + } } diff --git a/src/webhooks/webhooks.service.spec.ts b/src/webhooks/webhooks.service.spec.ts index b9223ba1..f8b8317c 100644 --- a/src/webhooks/webhooks.service.spec.ts +++ b/src/webhooks/webhooks.service.spec.ts @@ -1,16 +1,29 @@ +// @ts-nocheck import { Test, TestingModule } from '@nestjs/testing'; import { WebhooksService } from './webhooks.service'; import { PrismaService } from '../database/prisma.service'; -import { NotFoundException } from '@nestjs/common'; - -const mockPrisma = {}; describe('WebhooksService', () => { let service: WebhooksService; + let prisma: any; beforeEach(async () => { + prisma = { + webhook: { + create: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue(null), + update: jest.fn(), + delete: jest.fn(), + }, + webhookDeliveryLog: { + create: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), + }, + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [WebhooksService, { provide: PrismaService, useValue: mockPrisma }], + providers: [WebhooksService, { provide: PrismaService, useValue: prisma }], }).compile(); service = module.get(WebhooksService); @@ -21,29 +34,50 @@ describe('WebhooksService', () => { }); describe('create', () => { - it('should throw error (webhooks not yet implemented)', async () => { - await expect(service.create('user-1', {} as any)).rejects.toThrow( - 'Webhooks module not yet implemented', - ); + it('should create a webhook', async () => { + const dto = { + url: 'https://example.com/hook', + eventTypes: ['property.created'] as any, + secret: 'test-secret', + }; + prisma.webhook.create.mockResolvedValue({ + id: 'wh-1', + userId: 'user-1', + url: dto.url, + eventTypes: dto.eventTypes, + secret: dto.secret, + status: 'ACTIVE', + createdAt: new Date(), + }); + + const result = await service.create('user-1', dto); + expect(result).toBeDefined(); + expect(prisma.webhook.create).toHaveBeenCalled(); }); }); describe('findAll', () => { - it('should return empty array', async () => { + it('should return webhooks for a user', async () => { + prisma.webhook.findMany.mockResolvedValue([ + { id: 'wh-1', userId: 'user-1', url: 'https://example.com' }, + ]); + const result = await service.findAll('user-1'); - expect(result).toEqual([]); + expect(Array.isArray(result)).toBe(true); }); }); describe('findOne', () => { - it('should throw NotFoundException', async () => { - await expect(service.findOne('bad-id', 'user-1')).rejects.toThrow(NotFoundException); + it('should throw NotFoundException when webhook not found', async () => { + prisma.webhook.findFirst.mockResolvedValue(null); + await expect(service.findOne('bad-id', 'user-1')).rejects.toThrow(); }); }); describe('remove', () => { - it('should throw NotFoundException', async () => { - await expect(service.remove('1', 'user-1')).rejects.toThrow(NotFoundException); + it('should throw NotFoundException when webhook not found', async () => { + prisma.webhook.findFirst.mockResolvedValue(null); + await expect(service.remove('bad-id', 'user-1')).rejects.toThrow(); }); }); }); diff --git a/src/webhooks/webhooks.service.ts b/src/webhooks/webhooks.service.ts index ba8e61a1..7e7853cd 100644 --- a/src/webhooks/webhooks.service.ts +++ b/src/webhooks/webhooks.service.ts @@ -1,52 +1,210 @@ // @ts-nocheck -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; -import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto'; +import { CreateWebhookDto, UpdateWebhookDto, WebhookEventType } from './webhook.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; +import * as crypto from 'crypto'; @Injectable() export class WebhooksService { private readonly logger = new Logger(WebhooksService.name); - private readonly MAX_ATTEMPTS = 3; - private readonly RETRY_DELAYS = [60, 300, 900]; + private readonly MAX_ATTEMPTS = 5; + private readonly RETRY_DELAYS_MS = [1000, 5000, 15000, 60000, 300000]; // 1s, 5s, 15s, 60s, 300s constructor(private readonly prisma: PrismaService) {} - async create(_userId: string, _dto: CreateWebhookDto) { - throw new Error('Webhooks module not yet implemented - missing Prisma models'); + async create(userId: string, dto: CreateWebhookDto) { + const secret = crypto.randomBytes(32).toString('hex'); + const webhook = await this.prisma.webhook.create({ + data: { + userId, + url: dto.url, + secret, + events: dto.eventTypes, + description: dto.description, + }, + }); + return { ...webhook, secret }; } - async findAll(_userId: string) { - return []; + async findAll(userId: string) { + return this.prisma.webhook.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); } - async findOne(_id: string, _userId: string) { - throw new NotFoundException('Webhook not found'); + async findOne(id: string, userId: string) { + const webhook = await this.prisma.webhook.findFirst({ + where: { id, userId }, + }); + if (!webhook) throw new NotFoundException('Webhook not found'); + return webhook; } - async update(_id: string, _userId: string, _dto: UpdateWebhookDto) { - throw new NotFoundException('Webhook not found'); + async update(id: string, userId: string, dto: UpdateWebhookDto) { + await this.findOne(id, userId); + return this.prisma.webhook.update({ + where: { id }, + data: { + ...(dto.url && { url: dto.url }), + ...(dto.eventTypes && { events: dto.eventTypes }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.isActive !== undefined && { + status: dto.isActive ? 'ACTIVE' : 'INACTIVE', + }), + }, + }); } - async remove(_id: string, _userId: string) { - throw new NotFoundException('Webhook not found'); + async remove(id: string, userId: string) { + await this.findOne(id, userId); + await this.prisma.webhook.delete({ where: { id } }); + return { deleted: true }; } - async trigger(_eventType: string, _payload: object) { - this.logger.warn('Webhook trigger called but webhooks module not yet implemented'); + async trigger(eventType: string, payload: object) { + const webhooks = await this.prisma.webhook.findMany({ + where: { + status: 'ACTIVE', + events: { has: eventType }, + }, + }); + + for (const webhook of webhooks) { + await this.deliverWebhook(webhook, eventType, payload); + } + } + + async verifyChallenge(webhookId: string, userId: string, challenge: string) { + const webhook = await this.findOne(webhookId, userId); + try { + const url = new URL(webhook.url); + url.searchParams.set('challenge', challenge); + const response = await fetch(url.toString(), { method: 'GET', signal: AbortSignal.timeout(10000) }); + const body = await response.json(); + if (body.challenge === challenge) { + await this.prisma.webhook.update({ + where: { id: webhookId }, + data: { status: 'ACTIVE' }, + }); + return { verified: true }; + } + } catch (error) { + this.logger.warn(`Webhook verification failed for ${webhookId}: ${error.message}`); + } + return { verified: false }; + } + + async getDeliveries(webhookId: string, userId: string) { + await this.findOne(webhookId, userId); + return this.prisma.webhookDeliveryLog.findMany({ + where: { webhookId }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); } @Cron(CronExpression.EVERY_MINUTE) async retryFailedDeliveries() { - // No-op: webhooks module not yet implemented + const now = new Date(); + const pendingRetries = await this.prisma.webhookDeliveryLog.findMany({ + where: { + status: 'RETRYING', + nextRetryAt: { lte: now }, + attempts: { lt: this.MAX_ATTEMPTS }, + }, + include: { webhook: true }, + }); + + for (const delivery of pendingRetries) { + if (!delivery.webhook || delivery.webhook.status !== 'ACTIVE') continue; + await this.deliverWebhook( + delivery.webhook, + delivery.eventType, + delivery.payload as object, + ); + } } - async getDeliveries(_webhookId: string, _userId: string) { - return []; + private async deliverWebhook( + webhook: any, + eventType: string, + payload: object, + ) { + let delivery = await this.prisma.webhookDeliveryLog.create({ + data: { + webhookId: webhook.id, + eventType, + payload, + status: 'PENDING', + maxAttempts: this.MAX_ATTEMPTS, + }, + }); + + const body = JSON.stringify({ event: eventType, payload, timestamp: new Date().toISOString() }); + const signature = this.sign(body, webhook.secret); + + try { + const response = await fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signature, + 'X-Webhook-Event': eventType, + 'X-Webhook-Delivery-Id': delivery.id, + }, + body, + signal: AbortSignal.timeout(30000), + }); + + const responseText = await response.text().catch(() => ''); + + if (response.ok) { + delivery = await this.prisma.webhookDeliveryLog.update({ + where: { id: delivery.id }, + data: { + status: 'SUCCESS', + responseCode: response.status, + responseBody: responseText.substring(0, 2000), + attempts: delivery.attempts + 1, + deliveredAt: new Date(), + }, + }); + this.logger.log(`Webhook delivered: ${eventType} to ${webhook.url}`); + } else { + throw new Error(`HTTP ${response.status}: ${responseText.substring(0, 500)}`); + } + } catch (error) { + const nextAttempt = delivery.attempts + 1; + const shouldRetry = nextAttempt < this.MAX_ATTEMPTS; + + await this.prisma.webhookDeliveryLog.update({ + where: { id: delivery.id }, + data: { + status: shouldRetry ? 'RETRYING' : 'FAILED', + attempts: nextAttempt, + error: error.message, + responseBody: error.message.substring(0, 2000), + nextRetryAt: shouldRetry + ? new Date(Date.now() + this.RETRY_DELAYS_MS[nextAttempt] || this.RETRY_DELAYS_MS[this.RETRY_DELAYS_MS.length - 1]) + : null, + }, + }); + + this.logger.warn( + `Webhook delivery failed: ${eventType} to ${webhook.url} (attempt ${nextAttempt}/${this.MAX_ATTEMPTS})`, + ); + } } - private sign(_body: string, _secret: string): string { - return ''; + sign(body: string, secret: string): string { + return crypto.createHmac('sha256', secret).update(body).digest('hex'); } }