From fbbb43e3e2106bd3b5d727b196aca329bcc0ff52 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 24 May 2026 08:02:00 +0200 Subject: [PATCH] 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. --- apps/operator/pages/partners/[slug].vue | 390 ++++++++++++++++++ apps/operator/pages/partners/index.vue | 317 ++++++++++++++ .../api/partners/[slug]/index.delete.ts | 7 + .../server/api/partners/[slug]/index.get.ts | 6 + .../server/api/partners/[slug]/index.patch.ts | 7 + .../server/api/partners/[slug]/tenants.get.ts | 6 + .../operator/server/api/partners/index.get.ts | 3 + .../server/api/partners/index.post.ts | 6 + apps/operator/types/partner.ts | 28 ++ docs/OPERATOR-PLAN.md | 19 +- .../platform-api/src/schemas/tenant.schema.ts | 4 + .../src/tenants/tenants.service.ts | 21 +- 12 files changed, 807 insertions(+), 7 deletions(-) create mode 100644 apps/operator/pages/partners/[slug].vue create mode 100644 apps/operator/pages/partners/index.vue create mode 100644 apps/operator/server/api/partners/[slug]/index.delete.ts create mode 100644 apps/operator/server/api/partners/[slug]/index.get.ts create mode 100644 apps/operator/server/api/partners/[slug]/index.patch.ts create mode 100644 apps/operator/server/api/partners/[slug]/tenants.get.ts create mode 100644 apps/operator/server/api/partners/index.get.ts create mode 100644 apps/operator/server/api/partners/index.post.ts create mode 100644 apps/operator/types/partner.ts diff --git a/apps/operator/pages/partners/[slug].vue b/apps/operator/pages/partners/[slug].vue new file mode 100644 index 0000000..bc40c77 --- /dev/null +++ b/apps/operator/pages/partners/[slug].vue @@ -0,0 +1,390 @@ + + + + + diff --git a/apps/operator/pages/partners/index.vue b/apps/operator/pages/partners/index.vue new file mode 100644 index 0000000..b2f61a0 --- /dev/null +++ b/apps/operator/pages/partners/index.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/apps/operator/server/api/partners/[slug]/index.delete.ts b/apps/operator/server/api/partners/[slug]/index.delete.ts new file mode 100644 index 0000000..f79b145 --- /dev/null +++ b/apps/operator/server/api/partners/[slug]/index.delete.ts @@ -0,0 +1,7 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + await platformApi(event, `/partners/${slug}`, { method: 'DELETE' }) + return { ok: true } +}) diff --git a/apps/operator/server/api/partners/[slug]/index.get.ts b/apps/operator/server/api/partners/[slug]/index.get.ts new file mode 100644 index 0000000..0f69636 --- /dev/null +++ b/apps/operator/server/api/partners/[slug]/index.get.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/partners/${slug}`) +}) diff --git a/apps/operator/server/api/partners/[slug]/index.patch.ts b/apps/operator/server/api/partners/[slug]/index.patch.ts new file mode 100644 index 0000000..ba55cbb --- /dev/null +++ b/apps/operator/server/api/partners/[slug]/index.patch.ts @@ -0,0 +1,7 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + const body = await readBody(event) + return platformApi(event, `/partners/${slug}`, { method: 'PATCH', body }) +}) diff --git a/apps/operator/server/api/partners/[slug]/tenants.get.ts b/apps/operator/server/api/partners/[slug]/tenants.get.ts new file mode 100644 index 0000000..24544ec --- /dev/null +++ b/apps/operator/server/api/partners/[slug]/tenants.get.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/partners/${slug}/tenants`) +}) diff --git a/apps/operator/server/api/partners/index.get.ts b/apps/operator/server/api/partners/index.get.ts new file mode 100644 index 0000000..5bd048c --- /dev/null +++ b/apps/operator/server/api/partners/index.get.ts @@ -0,0 +1,3 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => platformApi(event, '/partners')) diff --git a/apps/operator/server/api/partners/index.post.ts b/apps/operator/server/api/partners/index.post.ts new file mode 100644 index 0000000..1a86990 --- /dev/null +++ b/apps/operator/server/api/partners/index.post.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + return platformApi(event, '/partners', { method: 'POST', body }) +}) diff --git a/apps/operator/types/partner.ts b/apps/operator/types/partner.ts new file mode 100644 index 0000000..8a9ec43 --- /dev/null +++ b/apps/operator/types/partner.ts @@ -0,0 +1,28 @@ +// Shape returned by /api/partners — matches Partner schema on platform-api, +// plus the `customers` count aggregated at the controller layer. + +export type PartnerStatus = 'active' | 'in-negotiation' | 'paused' | 'terminated' + +export interface Partner { + _id: string + slug: string + name: string + domain: string + status: PartnerStatus + marginPct: number + partnershipStartedAt?: string + contactInfo: { + primaryName?: string + primaryEmail?: string + billingEmail?: string + } + billingInfo: { + companyName?: string + vatId?: string + country?: string + contactEmail?: string + } + customers: number // computed at query time + createdAt: string + updatedAt: string +} diff --git a/docs/OPERATOR-PLAN.md b/docs/OPERATOR-PLAN.md index 90121c2..3494164 100644 --- a/docs/OPERATOR-PLAN.md +++ b/docs/OPERATOR-PLAN.md @@ -415,13 +415,20 @@ forward as bearer to platform-api. - Nitro can throw `Could not load /app/server/api/...` if Vite picks up a new file mid-build. Container restart clears it. -### O.6 · Partner management (real backend) +### O.6 · Partner management (real backend) ✓ -- [ ] `pages/partners/index.vue` — list with name/domain/status/customers/MRR -- [ ] `pages/partners/[slug].vue` — detail panel with customers list, - MRR breakdown, margin, contact info -- [ ] "Create partner" modal — POST /partners -- [ ] Attach / detach tenant to partner (PATCH on tenant.partnerId) +- [x] `pages/partners/index.vue` — list with name/domain/status/customers/margin +- [x] `pages/partners/[slug].vue` — detail panel with contract card, contact + card, customers table, attach modal, terminate danger card +- [x] "Create partner" modal — POST /partners, navigates to detail on success +- [x] Attach / detach tenant to partner (PATCH on tenant.partnerId, with $unset + for detach so the field disappears cleanly) +- [x] `services/platform-api/src/schemas/tenant.schema.ts` — added the + `partnerId` Prop. It was missing, which is why early PATCH attempts + returned 200 but Mongoose silently dropped the field. Smoke-tested with + acme ⇄ nordicmsp and a throwaway temp-msp partner (created + terminated). +- MRR aggregation deferred until Subscription gains real pricing (see + follow-ups). For now `customers` is just a count of attached tenants. ### O.7 · Visual-only screens (mock fixtures) diff --git a/services/platform-api/src/schemas/tenant.schema.ts b/services/platform-api/src/schemas/tenant.schema.ts index 11bcb8a..edc8d07 100644 --- a/services/platform-api/src/schemas/tenant.schema.ts +++ b/services/platform-api/src/schemas/tenant.schema.ts @@ -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 diff --git a/services/platform-api/src/tenants/tenants.service.ts b/services/platform-api/src/tenants/tenants.service.ts index 9c4f43f..f977fc8 100644 --- a/services/platform-api/src/tenants/tenants.service.ts +++ b/services/platform-api/src/tenants/tenants.service.ts @@ -62,8 +62,27 @@ export class TenantsService { } 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 }, dto, { new: true, runValidators: true }) + .findOneAndUpdate({ slug }, update, { new: true, runValidators: true }) .exec() if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`) return tenant