feat(operator): partner management with attach/detach (O.6)

- 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.
This commit is contained in:
Ronni Baslund
2026-05-24 08:02:00 +02:00
parent 8e81730372
commit fbbb43e3e2
12 changed files with 807 additions and 7 deletions
@@ -29,6 +29,10 @@ export class Tenant {
@Prop({ type: [String], default: [] })
domains!: string[]
// Optional MSP/reseller this tenant belongs to. Sparse — direct tenants have none.
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
partnerId?: Types.ObjectId
// External system handles — filled in by the provisioning worker (Phase 4)
@Prop({ index: true, sparse: true })
authentikGroupId?: string
@@ -62,8 +62,27 @@ export class TenantsService {
}
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 }, dto, { new: true, runValidators: true })
.findOneAndUpdate({ slug }, update, { new: true, runValidators: true })
.exec()
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
return tenant