import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; export interface CreateTenantDto { name: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string; } export interface UpdateTenantDto { name?: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string; } @Injectable() export class TenantsService { constructor(private readonly prisma: PrismaService) {} list() { return this.prisma.tenant.findMany({ include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } }, orderBy: { createdAt: 'desc' }, take: 100, }).then((items) => items.map(withEnterpriseProfile)); } async listManagementRows() { const sinceToday = startOfToday(); const [tenants, accounts, todaySpendGroups] = await Promise.all([ this.prisma.tenant.findMany({ where: { status: { not: 'deleted' } }, include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } }, orderBy: { createdAt: 'desc' }, take: 100, }), this.prisma.tenantAccount.findMany({ take: 200 }), this.prisma.smsMessageRecord.groupBy({ by: ['tenantId'], where: { queuedAt: { gte: sinceToday } }, _sum: { amountCents: true }, }), ]); const accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account])); const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0])); return tenants.map((tenant) => ({ ...withEnterpriseProfile(tenant), account: accountsByTenant.get(tenant.id) ?? null, todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0, })); } get(id: string) { return this.prisma.tenant.findUnique({ where: { id }, include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } }, }).then((tenant) => { if (!tenant) { throw new NotFoundException('Tenant not found'); } return withEnterpriseProfile(tenant); }); } async create(data: CreateTenantDto) { const code = data.code?.trim() || generateTenantCode(data); const tenant = await this.prisma.tenant.create({ data: { name: data.name, code, status: data.status ?? 'active' }, }); await this.upsertProfile(tenant.id, data); return this.get(tenant.id); } async update(id: string, data: UpdateTenantDto) { await this.ensureTenant(id); await this.prisma.tenant.update({ where: { id }, data: { name: data.name, code: data.code, status: data.status, }, }); await this.upsertProfile(id, data); return this.get(id); } async changeStatus(id: string, status: string) { await this.ensureTenant(id); return this.prisma.tenant.update({ where: { id }, data: { status }, }); } delete(id: string) { return this.changeStatus(id, 'deleted'); } private async ensureTenant(id: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id } }); if (!tenant) { throw new NotFoundException('Tenant not found'); } return tenant; } private async upsertProfile(tenantId: string, data: CreateTenantDto | UpdateTenantDto) { const hasProfileData = ['creditCode', 'province', 'city', 'address', 'contactName', 'contactIdCard', 'contactPhone', 'contactEmail', 'photoFileObjectId'] .some((key) => data[key as keyof (CreateTenantDto | UpdateTenantDto)] !== undefined); if (!hasProfileData) { return; } const latest = await this.prisma.enterpriseCertification.findFirst({ where: { tenantId }, orderBy: { submittedAt: 'desc' }, }); const materials = cleanObject({ ...(latest?.materials && typeof latest.materials === 'object' && !Array.isArray(latest.materials) ? latest.materials as Record : {}), province: data.province, city: data.city, address: data.address, contactIdCard: data.contactIdCard, contactEmail: data.contactEmail, photoFileObjectId: data.photoFileObjectId, }); const profileData = { companyName: data.name ?? latest?.companyName ?? tenantId, licenseNo: data.creditCode, contactName: data.contactName, contactPhone: data.contactPhone, materials: materials as Prisma.InputJsonValue, status: 'approved', }; if (latest) { await this.prisma.enterpriseCertification.update({ where: { id: latest.id }, data: profileData, }); return; } await this.prisma.enterpriseCertification.create({ data: { tenantId, ...profileData }, }); } } function cleanObject(value: Record) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); } function withEnterpriseProfile }>(tenant: T) { const [profile] = tenant.enterpriseCertifications ?? []; const materials = profile?.materials && typeof profile.materials === 'object' && !Array.isArray(profile.materials) ? profile.materials as Record : {}; const { enterpriseCertifications, ...rest } = tenant; return { ...rest, enterpriseProfile: profile ? { creditCode: profile.licenseNo ?? '', province: String(materials.province ?? ''), city: String(materials.city ?? ''), address: String(materials.address ?? ''), contactName: profile.contactName ?? '', contactIdCard: String(materials.contactIdCard ?? ''), contactPhone: profile.contactPhone ?? '', contactEmail: String(materials.contactEmail ?? ''), photoFileObjectId: String(materials.photoFileObjectId ?? ''), } : null, }; } function startOfToday() { const date = new Date(); date.setHours(0, 0, 0, 0); return date; } function generateTenantCode(data: CreateTenantDto) { const source = data.creditCode?.trim() || data.name.trim(); const normalized = source.replace(/[^\da-zA-Z]/g, '').toLowerCase(); const suffix = Date.now().toString(36).slice(-6); return `ent-${(normalized || 'tenant').slice(0, 18)}-${suffix}`; }