import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { TahesabCryptoService } from './tahesab-crypto.service'; import { TahesabHttpClient } from './tahesab-http.client'; import { TahesabApi } from './tahesab-methods'; import { CreateTahesabConnectionDto, UpdateTahesabConnectionDto, } from './dto'; function normalizeBaseUrl(url: string): string { let u = url.trim().replace(/\/+$/, ''); if (!/^https?:\/\//i.test(u)) { throw new BadRequestException('baseUrl باید با http:// یا https:// شروع شود.'); } return u; } @Injectable() export class TahesabConnectionsService { constructor( private readonly prisma: PrismaService, private readonly crypto: TahesabCryptoService, private readonly http: TahesabHttpClient, ) {} private toDto(row: { id: string; organizationId: string; provider: string; label: string; baseUrl: string; dbName: string; tokenLast4: string; enabled: boolean; isDefault: boolean; allowInsecureTls: boolean; lastHealthAt: Date | null; lastHealthOk: boolean | null; lastError: string | null; createdAt: Date; updatedAt: Date; }) { return { id: row.id, organizationId: row.organizationId, provider: row.provider, label: row.label, baseUrl: row.baseUrl, dbName: row.dbName, tokenMasked: row.tokenLast4 ? `••••••${row.tokenLast4}` : '••••', enabled: row.enabled, isDefault: row.isDefault, allowInsecureTls: row.allowInsecureTls, lastHealthAt: row.lastHealthAt?.toISOString() ?? null, lastHealthOk: row.lastHealthOk, lastError: row.lastError, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } async list(organizationId: string) { const rows = await this.prisma.accountingConnection.findMany({ where: { organizationId, provider: 'tahesab' }, orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }], }); return rows.map((r) => this.toDto(r)); } async getOrThrow(organizationId: string, id: string) { const row = await this.prisma.accountingConnection.findFirst({ where: { id, organizationId, provider: 'tahesab' }, }); if (!row) throw new NotFoundException('اتصال ته‌حساب یافت نشد.'); return row; } async getApi(organizationId: string, connectionId: string) { const row = await this.getOrThrow(organizationId, connectionId); if (!row.enabled) { throw new BadRequestException('این اتصال غیرفعال است.'); } const token = this.crypto.decrypt({ ciphertext: row.tokenCiphertext, iv: row.tokenIv, tag: row.tokenTag, }); return { row, api: new TahesabApi(this.http, { baseUrl: row.baseUrl, token, dbName: row.dbName, allowInsecureTls: row.allowInsecureTls, }), }; } async resolveConnectionId( organizationId: string, connectionId?: string | null, ) { if (connectionId) { await this.getOrThrow(organizationId, connectionId); return connectionId; } const def = await this.prisma.accountingConnection.findFirst({ where: { organizationId, provider: 'tahesab', enabled: true, isDefault: true }, }); if (def) return def.id; const any = await this.prisma.accountingConnection.findFirst({ where: { organizationId, provider: 'tahesab', enabled: true }, orderBy: { createdAt: 'asc' }, }); if (!any) { throw new BadRequestException( 'هیچ اتصال فعالی به ته‌حساب تعریف نشده است. از تنظیمات یک اتصال اضافه کنید.', ); } return any.id; } private async clearOtherDefaults(organizationId: string, exceptId?: string) { await this.prisma.accountingConnection.updateMany({ where: { organizationId, provider: 'tahesab', ...(exceptId ? { id: { not: exceptId } } : {}), }, data: { isDefault: false }, }); } async create(organizationId: string, dto: CreateTahesabConnectionDto) { const baseUrl = normalizeBaseUrl(dto.baseUrl); const existing = await this.prisma.accountingConnection.findFirst({ where: { organizationId, baseUrl, dbName: dto.dbName.trim() }, }); if (existing) { throw new ConflictException( 'اتصالی با همین آدرس و نام دیتابیس از قبل وجود دارد.', ); } const count = await this.prisma.accountingConnection.count({ where: { organizationId, provider: 'tahesab' }, }); const makeDefault = dto.isDefault === true || count === 0; if (makeDefault) await this.clearOtherDefaults(organizationId); const enc = this.crypto.encrypt(dto.token.trim()); const row = await this.prisma.accountingConnection.create({ data: { organizationId, provider: 'tahesab', label: dto.label.trim(), baseUrl, dbName: dto.dbName.trim(), tokenCiphertext: enc.ciphertext, tokenIv: enc.iv, tokenTag: enc.tag, tokenLast4: this.crypto.last4(dto.token), enabled: dto.enabled ?? true, isDefault: makeDefault, allowInsecureTls: dto.allowInsecureTls ?? false, }, }); return this.toDto(row); } async update( organizationId: string, id: string, dto: UpdateTahesabConnectionDto, ) { const row = await this.getOrThrow(organizationId, id); const baseUrl = dto.baseUrl ? normalizeBaseUrl(dto.baseUrl) : row.baseUrl; const dbName = dto.dbName?.trim() ?? row.dbName; if (baseUrl !== row.baseUrl || dbName !== row.dbName) { const clash = await this.prisma.accountingConnection.findFirst({ where: { organizationId, baseUrl, dbName, id: { not: id }, }, }); if (clash) { throw new ConflictException( 'اتصالی با همین آدرس و نام دیتابیس از قبل وجود دارد.', ); } } if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id); let enc = { ciphertext: row.tokenCiphertext, iv: row.tokenIv, tag: row.tokenTag, }; let tokenLast4 = row.tokenLast4; if (dto.token?.trim()) { enc = this.crypto.encrypt(dto.token.trim()); tokenLast4 = this.crypto.last4(dto.token); } const updated = await this.prisma.accountingConnection.update({ where: { id }, data: { label: dto.label?.trim() ?? row.label, baseUrl, dbName, tokenCiphertext: enc.ciphertext, tokenIv: enc.iv, tokenTag: enc.tag, tokenLast4, enabled: dto.enabled ?? row.enabled, isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault, allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls, }, }); return this.toDto(updated); } async remove(organizationId: string, id: string) { const row = await this.getOrThrow(organizationId, id); await this.prisma.accountingConnection.delete({ where: { id: row.id } }); if (row.isDefault) { const next = await this.prisma.accountingConnection.findFirst({ where: { organizationId, provider: 'tahesab', enabled: true }, orderBy: { createdAt: 'asc' }, }); if (next) { await this.prisma.accountingConnection.update({ where: { id: next.id }, data: { isDefault: true }, }); } } return { ok: true }; } async test(organizationId: string, id: string) { const { row, api } = await this.getApi(organizationId, id); try { const result = await api.checkHealth(); const patch: { lastHealthAt: Date; lastHealthOk: boolean; lastError: string | null; baseUrl?: string; allowInsecureTls?: boolean; } = { lastHealthAt: new Date(), lastHealthOk: true, lastError: null, }; // اگر health فقط با آدرس/پروتکل جایگزین جواب داد، همان را ذخیره کن if ( result.usedBaseUrl && result.usedBaseUrl.replace(/\/+$/, '') !== row.baseUrl.replace(/\/+$/, '') ) { patch.baseUrl = result.usedBaseUrl.replace(/\/+$/, ''); } // اگر فقط با TLS بدون verify جواب داد، فلگ را روشن کن تا بقیهٔ متدها هم کار کنند if (!row.allowInsecureTls && result.usedInsecureTls) { patch.allowInsecureTls = true; } const updated = await this.prisma.accountingConnection.update({ where: { id: row.id }, data: patch, }); return { ok: true, connection: this.toDto(updated), health: result, adjusted: Boolean(patch.baseUrl) || Boolean(patch.allowInsecureTls) ? { baseUrl: patch.baseUrl, allowInsecureTls: patch.allowInsecureTls, } : undefined, }; } catch (err) { const message = (err as Error).message ?? 'خطای ناشناخته'; const hint = /مهلت اتصال|timeout/i.test(message) ? ' از سرور CRM به IP/پورت ته‌حساب دسترسی شبکه لازم است (فایروال). برای https خودامضا گزینه SSL را فعال کنید. پورت پیش‌فرض: https 8081 یا http 9550.' : ''; const full = `${message}${hint}`.slice(0, 500); const updated = await this.prisma.accountingConnection.update({ where: { id: row.id }, data: { lastHealthAt: new Date(), lastHealthOk: false, lastError: full, }, }); return { ok: false, connection: this.toDto(updated), error: full }; } } }