import { BadGatewayException, BadRequestException, GoneException, Injectable, NotFoundException, } from "@nestjs/common"; import { Types } from "mongoose"; import { ClientDto, ClientDtoRs, ClientLists, } from "src/client/dto/create-client.dto"; import { type ClientSettingsPanelResponseDto, UpdateClientSettingsDto, } from "src/client/dto/client-settings.dto"; import type { ClientMediaLimits } from "./entities/schema/client.schema"; import { ClientDbService } from "./entities/db-service/client.db.service"; import { ClientExternalInquiriesCatalogDto, ClientExternalInquiriesListDto, ClientExternalInquiriesViewDto, toClientExternalInquiriesView, UpdateClientExternalInquiriesDto, } from "./dto/client-external-inquiries.dto"; import { EXTERNAL_INQUIRY_TYPES, mergeExternalInquiryFlags, } from "src/common/types/external-inquiry.types"; import { ExternalInquirySettingsService } from "./external-inquiry-settings.service"; import { SystemSettingsService } from "src/system-settings/system-settings.service"; /** * System-wide default applied when a client document has no * `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the * gate is enforced even for older / unconfigured clients. */ export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 5; /** * Media kinds we enforce upload-size bounds for. Each kind has its own * default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional * per-client override under `settings.media.`. */ export type MediaKind = | "video" | "image" | "voice" | "chassisNumber" | "carPlate"; export interface MediaLimits { /** Inclusive lower bound. `0` allows any size from zero up. */ minBytes: number; /** Inclusive upper bound. */ maxBytes: number; } /** * System defaults applied when a client (or kind) has no explicit override. * * Important: each upload route also has a `multer.limits.fileSize` hard * ceiling — those route ceilings are the absolute maximum the API can * receive. Per-client `maxBytes` cannot legitimately go above its route's * multer ceiling, so the defaults below are kept at-or-below those. */ export const DEFAULT_MEDIA_MIN_BYTES = 10; // 10 Byte export const DEFAULT_MEDIA_MAX_BYTES = 100 * 1024 * 1024; // 100MB export const DEFAULT_MEDIA_LIMITS: Record = { video: { minBytes: DEFAULT_MEDIA_MIN_BYTES, maxBytes: DEFAULT_MEDIA_MAX_BYTES, }, image: { minBytes: DEFAULT_MEDIA_MIN_BYTES, maxBytes: DEFAULT_MEDIA_MAX_BYTES, }, voice: { minBytes: DEFAULT_MEDIA_MIN_BYTES, maxBytes: DEFAULT_MEDIA_MAX_BYTES, }, carPlate: { minBytes: DEFAULT_MEDIA_MIN_BYTES, maxBytes: DEFAULT_MEDIA_MAX_BYTES, }, chassisNumber: { minBytes: DEFAULT_MEDIA_MIN_BYTES, maxBytes: DEFAULT_MEDIA_MAX_BYTES, }, }; /** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */ export const MEDIA_ROUTE_MAX_BYTES: Record = { video: DEFAULT_MEDIA_MAX_BYTES, image: DEFAULT_MEDIA_MAX_BYTES, voice: DEFAULT_MEDIA_MAX_BYTES, carPlate: DEFAULT_MEDIA_MAX_BYTES, chassisNumber: DEFAULT_MEDIA_MAX_BYTES, }; export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1; export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX = 365; const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"]; @Injectable() export class ClientService { constructor( private readonly clientDbService: ClientDbService, private readonly externalInquirySettingsService: ExternalInquirySettingsService, private readonly systemSettingsService: SystemSettingsService, ) {} async addClient(client: ClientDto): Promise { try { const smsApiKey = client.property?.smsApiKey?.trim(); const newClient = await this.clientDbService.create({ clientCode: client.clientCode, clientName: { persian: typeof client.clientName === "string" ? client.clientName : client.clientName?.persian, english: typeof client.clientName === "string" ? null : (client.clientName?.english ?? null), }, ...(smsApiKey ? { property: { smsApiKey } } : {}), useExpertMode: client.useExpertMode ?? null, }); if (!newClient) { throw new GoneException("database not connected"); } return new ClientDtoRs(newClient); } catch (er) { console.error("ADD CLIENT ERROR:", er); throw er; } } findOne(filter) { return this.clientDbService.findOne(filter); } async findClientWithPersianName(filter: string) { return await this.clientDbService.find({ "clientName.persian": filter }); } async findClientWithCompanyCode(companyCode: number) { return await this.clientDbService.find({ clientCode: companyCode }); } /** * Resolve a client by insurer company code from an external inquiry. * Creates the client when missing so inquiry flows don't fail on unknown codes. */ async findOrCreateClientByCompanyCode( companyCode: number | string | null | undefined, companyName: string | null | undefined, ) { const name = typeof companyName === "string" ? companyName.trim() : ""; const code = Number(companyCode); if (!name || !Number.isFinite(code)) { return null; } let client = await this.clientDbService.find({ clientCode: code }); if (client) return client; try { await this.addClient({ clientName: { persian: name, english: null }, clientCode: code, useExpertMode: "legal", }); } catch (err) { client = await this.clientDbService.find({ clientCode: code }); if (client) return client; throw err; } return this.clientDbService.find({ clientCode: code }); } async getClients(): Promise { const clients = await this.clientDbService.findAll(); const show = clients.map((c) => new ClientDtoRs(c)); return show; } async getClientList(): Promise { const client = await this.clientDbService.findAll(); const list = client.map((element) => new ClientLists(element)); return list; } /** * Resolve the per-client byte bounds for a media kind. Missing bounds fall * back to {@link DEFAULT_MEDIA_LIMITS}. If no clientId is supplied (or it * doesn't resolve to a client document) the defaults are returned. * * The returned object is always fully populated — callers can compare * directly against `file.size`. */ async getMediaLimits( clientId: string | Types.ObjectId | null | undefined, kind: MediaKind, ): Promise { const defaults = DEFAULT_MEDIA_LIMITS[kind]; if (!clientId) return { ...defaults }; const idString = String(clientId); if (!Types.ObjectId.isValid(idString)) return { ...defaults }; const client = await this.clientDbService.findOne({ _id: new Types.ObjectId(idString), }); const override = client?.settings?.media?.[kind]; const minBytes = typeof override?.minBytes === "number" && Number.isFinite(override.minBytes) && override.minBytes >= 0 ? override.minBytes : defaults.minBytes; const maxBytes = typeof override?.maxBytes === "number" && Number.isFinite(override.maxBytes) && override.maxBytes > 0 ? override.maxBytes : defaults.maxBytes; return { minBytes, maxBytes }; } /** * Resolve the per-client CAR_BODY accident-age window (in days). * * Falls back to {@link DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS} when: * - no client id is supplied, * - the id is malformed, * - the client document is missing, * - the client has no `settings.carBodyAccidentMaxAgeDays` configured, * - or the configured value is not a positive finite number. */ async getCarBodyAccidentMaxAgeDays( clientId?: string | Types.ObjectId | null, ): Promise { if (!clientId) return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS; const idString = String(clientId); if (!Types.ObjectId.isValid(idString)) { return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS; } const client = await this.clientDbService.findOne({ _id: new Types.ObjectId(idString), }); const configured = client?.settings?.carBodyAccidentMaxAgeDays; if ( typeof configured === "number" && Number.isFinite(configured) && configured > 0 ) { return configured; } return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS; } private resolveClientObjectId( clientKey: string | Types.ObjectId, ): Types.ObjectId { const raw = String(clientKey ?? "").trim(); if (!Types.ObjectId.isValid(raw)) { throw new BadRequestException("Invalid client key"); } return new Types.ObjectId(raw); } private async loadClientOrThrow(clientKey: string | Types.ObjectId) { const id = this.resolveClientObjectId(clientKey); const client = await this.clientDbService.findOne({ _id: id }); if (!client) { throw new NotFoundException("Client not found"); } return client; } private configuredMediaLimits( stored: ClientMediaLimits | undefined, ): { minBytes?: number; maxBytes?: number } | null { if (!stored) return null; const hasMin = typeof stored.minBytes === "number"; const hasMax = typeof stored.maxBytes === "number"; if (!hasMin && !hasMax) return null; return { ...(hasMin ? { minBytes: stored.minBytes } : {}), ...(hasMax ? { maxBytes: stored.maxBytes } : {}), }; } private validateMediaLimitsPatch( kind: MediaKind, patch: { minBytes?: number; maxBytes?: number }, ): { minBytes?: number; maxBytes?: number } { const defaults = DEFAULT_MEDIA_LIMITS[kind]; const routeMax = MEDIA_ROUTE_MAX_BYTES[kind]; const minBytes = patch.minBytes !== undefined ? patch.minBytes : defaults.minBytes; const maxBytes = patch.maxBytes !== undefined ? patch.maxBytes : defaults.maxBytes; if (maxBytes > routeMax) { throw new BadRequestException( `${kind} maxBytes (${maxBytes}) cannot exceed the route upload limit of ${routeMax} bytes.`, ); } if (minBytes > maxBytes) { throw new BadRequestException( `${kind} minBytes (${minBytes}) cannot be greater than maxBytes (${maxBytes}).`, ); } const out: { minBytes?: number; maxBytes?: number } = {}; if (patch.minBytes !== undefined) out.minBytes = patch.minBytes; if (patch.maxBytes !== undefined) out.maxBytes = patch.maxBytes; return out; } async getPanelSettings( clientKey: string | Types.ObjectId, ): Promise { const client = await this.loadClientOrThrow(clientKey); const clientId = client._id.toString(); const configuredDays = client.settings?.carBodyAccidentMaxAgeDays; const effectiveDays = await this.getCarBodyAccidentMaxAgeDays(client._id); const media = {} as ClientSettingsPanelResponseDto["media"]; for (const kind of MEDIA_KINDS) { const effective = await this.getMediaLimits(client._id, kind); media[kind] = { configured: this.configuredMediaLimits(client.settings?.media?.[kind]), effective, systemDefault: { ...DEFAULT_MEDIA_LIMITS[kind] }, routeMaxBytes: MEDIA_ROUTE_MAX_BYTES[kind], }; } return { clientId, carBodyAccidentMaxAgeDays: { configured: typeof configuredDays === "number" && Number.isFinite(configuredDays) ? configuredDays : null, effective: effectiveDays, systemDefault: DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS, minDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN, maxDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX, }, media, }; } async updatePanelSettings( clientKey: string | Types.ObjectId, body: UpdateClientSettingsDto, ): Promise { const client = await this.loadClientOrThrow(clientKey); const $set: Record = {}; if (body.carBodyAccidentMaxAgeDays !== undefined) { $set["settings.carBodyAccidentMaxAgeDays"] = body.carBodyAccidentMaxAgeDays; } if (body.media) { for (const kind of MEDIA_KINDS) { const patch = body.media[kind]; if (!patch) continue; const validated = this.validateMediaLimitsPatch(kind, patch); if (validated.minBytes !== undefined) { $set[`settings.media.${kind}.minBytes`] = validated.minBytes; } if (validated.maxBytes !== undefined) { $set[`settings.media.${kind}.maxBytes`] = validated.maxBytes; } } } if (Object.keys($set).length === 0) { throw new BadRequestException("No settings fields to update."); } const updated = await this.clientDbService.findByIdAndUpdate( client._id.toString(), { $set }, ); if (!updated) { throw new NotFoundException("Client not found"); } return this.getPanelSettings(client._id); } getExternalInquiriesCatalog(): ClientExternalInquiriesCatalogDto { return { inquiryTypes: [...EXTERNAL_INQUIRY_TYPES], globalSettingPath: "/system-settings (PATCH externalApis.sandHubUseLiveApi)", perClientSettingPath: "/client/{clientId}/external-inquiries", }; } async listExternalInquirySettings(): Promise { const global = await this.systemSettingsService.isSandHubLiveEnabled(); const clients = await this.clientDbService.findAll(); return { items: clients.map((c) => toClientExternalInquiriesView(c, global)), }; } async getExternalInquirySettings( clientKey: string | Types.ObjectId, ): Promise { const client = await this.loadClientOrThrow(clientKey); const global = await this.systemSettingsService.isSandHubLiveEnabled(); return toClientExternalInquiriesView(client, global); } async replaceExternalInquirySettings( clientKey: string | Types.ObjectId, body: UpdateClientExternalInquiriesDto, ): Promise { const client = await this.loadClientOrThrow(clientKey); const flags = mergeExternalInquiryFlags(body as Partial>); const $set: Record = {}; for (const key of EXTERNAL_INQUIRY_TYPES) { $set[`settings.externalInquiries.${key}`] = flags[key]; } const updated = await this.clientDbService.findByIdAndUpdate( client._id.toString(), { $set }, ); if (!updated) throw new NotFoundException("Client not found"); this.externalInquirySettingsService.invalidateClientCache( client._id.toString(), ); return this.getExternalInquirySettings(client._id); } async patchExternalInquirySettings( clientKey: string | Types.ObjectId, body: UpdateClientExternalInquiriesDto, ): Promise { const client = await this.loadClientOrThrow(clientKey); const $set: Record = {}; for (const key of EXTERNAL_INQUIRY_TYPES) { if (body[key] !== undefined) { $set[`settings.externalInquiries.${key}`] = body[key]; } } if (Object.keys($set).length === 0) { throw new BadRequestException("No external inquiry flags to update."); } const updated = await this.clientDbService.findByIdAndUpdate( client._id.toString(), { $set }, ); if (!updated) throw new NotFoundException("Client not found"); this.externalInquirySettingsService.invalidateClientCache( client._id.toString(), ); return this.getExternalInquirySettings(client._id); } }