import { Injectable, NotFoundException, ConflictException, BadRequestException, Inject, } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { PrismaService } from "@bff/infra/database/prisma.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { MappingCacheService } from "./cache/mapping-cache.service"; import { MappingValidatorService } from "./validation/mapping-validator.service"; import { UserIdMapping, CreateMappingRequest, UpdateMappingRequest, MappingSearchFilters, MappingStats, BulkMappingResult, } from "./types/mapping.types"; @Injectable() export class MappingsService { constructor( private readonly prisma: PrismaService, private readonly cacheService: MappingCacheService, private readonly validator: MappingValidatorService, @Inject(Logger) private readonly logger: Logger ) {} async createMapping(request: CreateMappingRequest): Promise { try { const validation = this.validator.validateCreateRequest(request); this.logger.debug("Validation result: Create mapping", validation); if (!validation.isValid) { throw new BadRequestException(`Invalid mapping data: ${validation.errors.join(", ")}`); } const sanitizedRequest = this.validator.sanitizeCreateRequest(request); const [byUser, byWhmcs, bySf] = await Promise.all([ this.prisma.idMapping.findUnique({ where: { userId: sanitizedRequest.userId } }), this.prisma.idMapping.findUnique({ where: { whmcsClientId: sanitizedRequest.whmcsClientId } }), sanitizedRequest.sfAccountId ? this.prisma.idMapping.findFirst({ where: { sfAccountId: sanitizedRequest.sfAccountId } }) : Promise.resolve(null), ]); if (byUser) { throw new ConflictException(`User ${sanitizedRequest.userId} already has a mapping`); } if (byWhmcs) { throw new ConflictException( `WHMCS client ${sanitizedRequest.whmcsClientId} is already mapped to user ${byWhmcs.userId}` ); } if (bySf) { this.logger.warn( `Salesforce account ${sanitizedRequest.sfAccountId} is already mapped to user ${bySf.userId}` ); } let created; try { created = await this.prisma.idMapping.create({ data: sanitizedRequest }); } catch (e) { const msg = getErrorMessage(e); if (msg.includes("P2002") || /unique/i.test(msg)) { throw new ConflictException("Mapping violates uniqueness constraints"); } throw e; } const mapping: UserIdMapping = { userId: created.userId, whmcsClientId: created.whmcsClientId, sfAccountId: created.sfAccountId || undefined, createdAt: created.createdAt, updatedAt: created.updatedAt, }; await this.cacheService.setMapping(mapping); this.logger.log(`Created mapping for user ${mapping.userId}`, { whmcsClientId: mapping.whmcsClientId, sfAccountId: mapping.sfAccountId, warnings: validation.warnings, }); return mapping; } catch (error) { this.logger.error(`Failed to create mapping for user ${request.userId}`, { error: getErrorMessage(error), request: this.sanitizeForLog(request), }); throw error; } } async findBySfAccountId(sfAccountId: string): Promise { try { if (!sfAccountId) { throw new BadRequestException("Salesforce Account ID is required"); } const cached = await this.cacheService.getBySfAccountId(sfAccountId); if (cached) { this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`); return cached; } const dbMapping = await this.prisma.idMapping.findFirst({ where: { sfAccountId } }); if (!dbMapping) { this.logger.debug(`No mapping found for SF account ${sfAccountId}`); return null; } const mapping: UserIdMapping = { userId: dbMapping.userId, whmcsClientId: dbMapping.whmcsClientId, sfAccountId: dbMapping.sfAccountId || undefined, createdAt: dbMapping.createdAt, updatedAt: dbMapping.updatedAt, }; await this.cacheService.setMapping(mapping); this.logger.debug(`Found mapping for SF account ${sfAccountId}`, { userId: mapping.userId, whmcsClientId: mapping.whmcsClientId, }); return mapping; } catch (error) { this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, { error: getErrorMessage(error), }); throw error; } } async findByUserId(userId: string): Promise { try { if (!userId) { throw new BadRequestException("User ID is required"); } const cached = await this.cacheService.getByUserId(userId); if (cached) { this.logger.debug(`Cache hit for user mapping: ${userId}`); return cached; } const dbMapping = await this.prisma.idMapping.findUnique({ where: { userId } }); if (!dbMapping) { this.logger.debug(`No mapping found for user ${userId}`); return null; } const mapping: UserIdMapping = { userId: dbMapping.userId, whmcsClientId: dbMapping.whmcsClientId, sfAccountId: dbMapping.sfAccountId || undefined, createdAt: dbMapping.createdAt, updatedAt: dbMapping.updatedAt, }; await this.cacheService.setMapping(mapping); this.logger.debug(`Found mapping for user ${userId}`, { whmcsClientId: mapping.whmcsClientId, sfAccountId: mapping.sfAccountId, }); return mapping; } catch (error) { this.logger.error(`Failed to find mapping for user ${userId}`, { error: getErrorMessage(error), }); throw error; } } async findByWhmcsClientId(whmcsClientId: number): Promise { try { if (!whmcsClientId || whmcsClientId < 1) { throw new BadRequestException("Valid WHMCS client ID is required"); } const cached = await this.cacheService.getByWhmcsClientId(whmcsClientId); if (cached) { this.logger.debug(`Cache hit for WHMCS client mapping: ${whmcsClientId}`); return cached; } const dbMapping = await this.prisma.idMapping.findUnique({ where: { whmcsClientId } }); if (!dbMapping) { this.logger.debug(`No mapping found for WHMCS client ${whmcsClientId}`); return null; } const mapping: UserIdMapping = { userId: dbMapping.userId, whmcsClientId: dbMapping.whmcsClientId, sfAccountId: dbMapping.sfAccountId || undefined, createdAt: dbMapping.createdAt, updatedAt: dbMapping.updatedAt, }; await this.cacheService.setMapping(mapping); this.logger.debug(`Found mapping for WHMCS client ${whmcsClientId}`, { userId: mapping.userId, sfAccountId: mapping.sfAccountId, }); return mapping; } catch (error) { this.logger.error(`Failed to find mapping for WHMCS client ${whmcsClientId}`, { error: getErrorMessage(error), }); throw error; } } async updateMapping(userId: string, updates: UpdateMappingRequest): Promise { try { const validation = this.validator.validateUpdateRequest(userId, updates); this.logger.debug("Validation result: Update mapping", validation); if (!validation.isValid) { throw new BadRequestException(`Invalid update data: ${validation.errors.join(", ")}`); } const existing = await this.findByUserId(userId); if (!existing) { throw new NotFoundException(`Mapping not found for user ${userId}`); } const sanitizedUpdates = this.validator.sanitizeUpdateRequest(updates); if (sanitizedUpdates.whmcsClientId && sanitizedUpdates.whmcsClientId !== existing.whmcsClientId) { const conflictingMapping = await this.findByWhmcsClientId(sanitizedUpdates.whmcsClientId); if (conflictingMapping && conflictingMapping.userId !== userId) { throw new ConflictException( `WHMCS client ${sanitizedUpdates.whmcsClientId} is already mapped to user ${conflictingMapping.userId}` ); } } const updated = await this.prisma.idMapping.update({ where: { userId }, data: sanitizedUpdates }); const newMapping: UserIdMapping = { userId: updated.userId, whmcsClientId: updated.whmcsClientId, sfAccountId: updated.sfAccountId || undefined, createdAt: updated.createdAt, updatedAt: updated.updatedAt, }; await this.cacheService.updateMapping(existing, newMapping); this.logger.log(`Updated mapping for user ${userId}`, { changes: sanitizedUpdates, warnings: validation.warnings, }); return newMapping; } catch (error) { this.logger.error(`Failed to update mapping for user ${userId}`, { error: getErrorMessage(error), updates: this.sanitizeForLog(updates), }); throw error; } } async deleteMapping(userId: string): Promise { try { const existing = await this.findByUserId(userId); if (!existing) { throw new NotFoundException(`Mapping not found for user ${userId}`); } const validation = this.validator.validateDeletion(existing); this.logger.debug("Validation result: Delete mapping", validation); await this.prisma.idMapping.delete({ where: { userId } }); await this.cacheService.deleteMapping(existing); this.logger.log(`Deleted mapping for user ${userId}`, { whmcsClientId: existing.whmcsClientId, sfAccountId: existing.sfAccountId, warnings: validation.warnings, }); } catch (error) { this.logger.error(`Failed to delete mapping for user ${userId}`, { error: getErrorMessage(error), }); throw error; } } async searchMappings(filters: MappingSearchFilters): Promise { try { const whereClause: Record = {}; if (filters.userId) whereClause.userId = filters.userId; if (filters.whmcsClientId) whereClause.whmcsClientId = filters.whmcsClientId; if (filters.sfAccountId) whereClause.sfAccountId = filters.sfAccountId; if (filters.hasWhmcsMapping !== undefined) { whereClause.whmcsClientId = filters.hasWhmcsMapping ? { not: null } : null; } if (filters.hasSfMapping !== undefined) { whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : null; } const dbMappings = await this.prisma.idMapping.findMany({ where: whereClause, orderBy: { createdAt: "desc" } }); const mappings: UserIdMapping[] = dbMappings.map(mapping => ({ userId: mapping.userId, whmcsClientId: mapping.whmcsClientId, sfAccountId: mapping.sfAccountId || undefined, createdAt: mapping.createdAt, updatedAt: mapping.updatedAt, })); this.logger.debug(`Found ${mappings.length} mappings matching filters`, filters); return mappings; } catch (error) { this.logger.error("Failed to search mappings", { error: getErrorMessage(error), filters }); throw error; } } async getMappingStats(): Promise { try { const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([ this.prisma.idMapping.count(), this.prisma.idMapping.count(), this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), ]); const stats: MappingStats = { totalMappings: totalCount, whmcsMappings: whmcsCount, salesforceMappings: sfCount, completeMappings: completeCount, orphanedMappings: 0, }; this.logger.debug("Generated mapping statistics", stats); return stats; } catch (error) { this.logger.error("Failed to get mapping statistics", { error: getErrorMessage(error) }); throw error; } } async hasMapping(userId: string): Promise { try { const cached = await this.cacheService.getByUserId(userId); if (cached) return true; const mapping = await this.prisma.idMapping.findUnique({ where: { userId }, select: { userId: true } }); return mapping !== null; } catch (error) { this.logger.error(`Failed to check mapping for user ${userId}`, { error: getErrorMessage(error) }); return false; } } async invalidateCache(userId: string): Promise { const mapping = await this.cacheService.getByUserId(userId); if (mapping) { await this.cacheService.deleteMapping(mapping); } this.logger.log(`Invalidated mapping cache for user ${userId}`); } private sanitizeForLog(data: unknown): Record { try { const plain: unknown = JSON.parse(JSON.stringify(data ?? {})); if (plain && typeof plain === "object" && !Array.isArray(plain)) { return plain as Record; } return { value: plain } as Record; } catch { return { value: String(data) } as Record; } } }