import { ConflictException, Injectable, NotFoundException } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model, Types } from 'mongoose' import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' import { User, UserDocument } from '../schemas/user.schema.js' import type { CreateTenantDto } from './dto/create-tenant.dto.js' import type { UpdateTenantDto } from './dto/update-tenant.dto.js' import { ProvisioningService } from './provisioning.service.js' @Injectable() export class TenantsService { constructor( @InjectModel(Tenant.name) private readonly tenantModel: Model, @InjectModel(User.name) private readonly userModel: Model, private readonly provisioning: ProvisioningService, ) {} async listUsersForTenant(slug: string): Promise { const tenant = await this.findOneBySlug(slug) return this.userModel .find({ tenantIds: tenant._id }) .sort({ lastLoginAt: -1, createdAt: -1 }) .exec() } async create(dto: CreateTenantDto): Promise { const exists = await this.tenantModel.exists({ slug: dto.slug }) if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`) const tenant = await this.tenantModel.create({ ...dto, status: 'pending' }) // Provision external resources best-effort. Errors are recorded on the doc; // the caller can re-POST or call /tenants/:slug/reconcile to retry. return this.provisioning.reconcile(tenant) } async reconcile(slug: string): Promise { const tenant = await this.findOneBySlug(slug) return this.provisioning.reconcile(tenant) } async findAll(): Promise { return this.tenantModel.find().sort({ createdAt: -1 }).exec() } async findByIds(ids: Types.ObjectId[]): Promise { if (ids.length === 0) return [] return this.tenantModel .find({ _id: { $in: ids } }) .sort({ createdAt: -1 }) .exec() } async findOneBySlug(slug: string): Promise { const tenant = await this.tenantModel.findOne({ slug }).exec() if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`) return tenant } async findOneById(id: string | Types.ObjectId): Promise { const tenant = await this.tenantModel.findById(id).exec() if (!tenant) throw new NotFoundException(`Tenant ${id} not found`) return tenant } async update(slug: string, dto: UpdateTenantDto): Promise { // Build $set / $unset explicitly. Doing `findOneAndUpdate({slug}, dto, ...)` // with a class-transformer instance leaks undefined slots into the update, // and Mongoose doesn't always cast string→ObjectId for ref fields when // wrapped that way. Explicit $set keeps the intent clear and handles the // detach case (`partnerId: null`) cleanly via $unset. const set: Record = {} const unset: Record = {} if (dto.name !== undefined) set.name = dto.name if (dto.status !== undefined) set.status = dto.status if (dto.plan !== undefined) set.plan = dto.plan if (dto.domains !== undefined) set.domains = dto.domains if (dto.partnerId !== undefined) { if (dto.partnerId === null) unset.partnerId = '' else set.partnerId = new Types.ObjectId(dto.partnerId) } const update: Record = {} if (Object.keys(set).length) update.$set = set if (Object.keys(unset).length) update.$unset = unset const tenant = await this.tenantModel .findOneAndUpdate({ slug }, update, { new: true, runValidators: true }) .exec() if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`) return tenant } async softDelete(slug: string): Promise { const result = await this.tenantModel .updateOne({ slug }, { status: 'deleted' }) .exec() if (result.matchedCount === 0) throw new NotFoundException(`Tenant "${slug}" not found`) } async setStatus(slug: string, status: 'active' | 'suspended'): Promise { const tenant = await this.tenantModel .findOneAndUpdate({ slug }, { status }, { new: true }) .exec() if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`) return tenant } }