2026-09-04 22:00:23 +03:30
|
|
|
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 {
|
2026-10-03 16:45:04 +03:30
|
|
|
const result = await api.checkHealth();
|
2026-09-04 22:00:23 +03:30
|
|
|
const updated = await this.prisma.accountingConnection.update({
|
|
|
|
|
where: { id: row.id },
|
|
|
|
|
data: {
|
|
|
|
|
lastHealthAt: new Date(),
|
|
|
|
|
lastHealthOk: true,
|
|
|
|
|
lastError: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return { ok: true, connection: this.toDto(updated), health: result };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = (err as Error).message ?? 'خطای ناشناخته';
|
2026-10-03 16:45:04 +03:30
|
|
|
const hint =
|
|
|
|
|
/مهلت اتصال|timeout/i.test(message) && !row.allowInsecureTls && /^https:/i.test(row.baseUrl)
|
|
|
|
|
? ' اگر گواهی تهحساب خودامضاست، «پذیرش گواهی SSL خودامضا» را فعال کنید.'
|
|
|
|
|
: '';
|
|
|
|
|
const full = `${message}${hint}`.slice(0, 500);
|
2026-09-04 22:00:23 +03:30
|
|
|
const updated = await this.prisma.accountingConnection.update({
|
|
|
|
|
where: { id: row.id },
|
|
|
|
|
data: {
|
|
|
|
|
lastHealthAt: new Date(),
|
|
|
|
|
lastHealthOk: false,
|
2026-10-03 16:45:04 +03:30
|
|
|
lastError: full,
|
2026-09-04 22:00:23 +03:30
|
|
|
},
|
|
|
|
|
});
|
2026-10-03 16:45:04 +03:30
|
|
|
return { ok: false, connection: this.toDto(updated), error: full };
|
2026-09-04 22:00:23 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|