fbbb43e3e2
- Partners list with name/domain/status/customers/margin + Create modal - Partner detail: contract card, contact card, customers table, attach modal, terminate (soft-delete) danger card - Operator proxies for /partners + /partners/:slug/tenants - platform-api: add partnerId Prop to Tenant schema. The field was being silently dropped by Mongoose because the schema didn't declare it. - tenants.service: rewrite update() to build $set/$unset explicitly and cast partnerId via new Types.ObjectId(). Handles detach via $unset so the field vanishes from the doc cleanly.
106 lines
4.3 KiB
TypeScript
106 lines
4.3 KiB
TypeScript
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<TenantDocument>,
|
|
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
|
private readonly provisioning: ProvisioningService,
|
|
) {}
|
|
|
|
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
|
|
const tenant = await this.findOneBySlug(slug)
|
|
return this.userModel
|
|
.find({ tenantIds: tenant._id })
|
|
.sort({ lastLoginAt: -1, createdAt: -1 })
|
|
.exec()
|
|
}
|
|
|
|
async create(dto: CreateTenantDto): Promise<TenantDocument> {
|
|
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<TenantDocument> {
|
|
const tenant = await this.findOneBySlug(slug)
|
|
return this.provisioning.reconcile(tenant)
|
|
}
|
|
|
|
async findAll(): Promise<TenantDocument[]> {
|
|
return this.tenantModel.find().sort({ createdAt: -1 }).exec()
|
|
}
|
|
|
|
async findByIds(ids: Types.ObjectId[]): Promise<TenantDocument[]> {
|
|
if (ids.length === 0) return []
|
|
return this.tenantModel
|
|
.find({ _id: { $in: ids } })
|
|
.sort({ createdAt: -1 })
|
|
.exec()
|
|
}
|
|
|
|
async findOneBySlug(slug: string): Promise<TenantDocument> {
|
|
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<TenantDocument> {
|
|
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<TenantDocument> {
|
|
// 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<string, unknown> = {}
|
|
const unset: Record<string, ''> = {}
|
|
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<string, unknown> = {}
|
|
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<void> {
|
|
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<TenantDocument> {
|
|
const tenant = await this.tenantModel
|
|
.findOneAndUpdate({ slug }, { status }, { new: true })
|
|
.exec()
|
|
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
|
|
return tenant
|
|
}
|
|
}
|