import { Body, Controller, Delete, ForbiddenException, Get, Param, Patch, Post, Put, Query, Req, UseGuards, } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import type { Model } from 'mongoose' import { ActorService } from '../auth/actor.service.js' import { clientIp } from '../auth/client-ip.js' import { CurrentUser } from '../auth/current-user.decorator.js' import { JwtAuthGuard } from '../auth/jwt-auth.guard.js' import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js' import { PartnersService } from '../partners/partners.service.js' import { Partner, PartnerDocument } from '../schemas/partner.schema.js' import { CreateTenantDto } from '../tenants/dto/create-tenant.dto.js' import { TenantsService } from '../tenants/tenants.service.js' import { InvitePartnerUserDto } from '../users/dto/invite-partner-user.dto.js' import { UsersService } from '../users/users.service.js' import { CreateReportDto } from './dto/create-report.dto.js' import { PartnerBrandingDto } from './dto/partner-branding.dto.js' import { PartnerSettingsDto } from './dto/partner-settings.dto.js' import { PartnerUpdateTenantDto } from './dto/partner-update-tenant.dto.js' import { PartnerBrandingService } from './partner-branding.service.js' import { PartnerReportsService } from './partner-reports.service.js' // Self-service endpoints for the partner portal. Everything here scopes to // the caller's resolved User.partnerId — no slug in any URL, no operator // guard. A portal-aud JWT is sufficient; partner-staff is enforced per // handler via the actor.partnerId check. // // Identity endpoints (GET /users/me, etc.) intentionally stay on // UsersController — those are about "who am I", whereas everything here is // about "what does my partner own". @Controller('me/partner') @UseGuards(JwtAuthGuard) export class PartnerMeController { constructor( private readonly users: UsersService, private readonly tenants: TenantsService, private readonly partners: PartnersService, private readonly branding: PartnerBrandingService, private readonly reports: PartnerReportsService, private readonly actor: ActorService, @InjectModel(Partner.name) private readonly partnerModel: Model, ) {} // The OTHER people at the caller's partner organization. Distinct from // GET /partners/:slug/users (operator only): scoped via the actor's // User.partnerId so a portal-aud JWT works. @Get('users') async listUsers(@CurrentUser() jwt: AuthentikJwtPayload) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } return this.users.listPartnerUsersEnriched(actor.partnerId) } // Self-service partner-staff invite. Counterpart to the operator-only // POST /partners/:slug/users — scoped to the caller's own partner. @Post('users') async inviteUser( @Body() dto: InvitePartnerUserDto, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const partner = await this.partnerModel.findById(actor.partnerId, { slug: 1 }).exec() if (!partner) { throw new ForbiddenException('Partner record missing') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.users.invitePartnerUser(dto, { _id: actor.partnerId, slug: partner.slug }, auditActor) } // Remove a teammate from the partner organization. @Delete('users/:subject') async removeUser( @Param('subject') subject: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } if (subject === jwt.sub) { throw new ForbiddenException('You cannot remove your own account') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.users.removePartnerUser(subject, actor.partnerId, auditActor) } // Tenants (customers) attached to the partner. @Get('tenants') async listTenants(@CurrentUser() jwt: AuthentikJwtPayload) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } return this.users.listPartnerTenants(actor.partnerId) } // Self-service tenant create. Counterpart to operator POST /tenants. // Forces partnerId from actor.partnerId so a partner can never create // a tenant under a different partner, even if their payload says so. // If adminName + adminEmail are present, fires inviteTenantAdmin after // tenant provisioning. Admin failures don't roll back the tenant — the // response includes an `adminInvite` field with credentials or an error. @Post('tenants') async createTenant( @Body() dto: CreateTenantDto, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req), } const safeDto = { ...dto, partnerId: actor.partnerId } as CreateTenantDto & { partnerId: typeof actor.partnerId } const tenant = await this.tenants.create(safeDto, auditActor) let adminInvite: | { subject: string; userId: string; attached?: boolean; link?: string; tempPassword?: string } | { error: string } | undefined if (dto.adminName && dto.adminEmail) { try { adminInvite = await this.users.inviteTenantAdmin( { _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId }, { name: dto.adminName, email: dto.adminEmail }, auditActor, ) } catch (err) { adminInvite = { error: err instanceof Error ? err.message : String(err) } } } return { tenant, adminInvite } } // Edit a customer the partner owns (name, industry, brand colour, domains, // seats). Ownership is enforced in TenantsService.partnerUpdate. @Patch('tenants/:slug') async updateTenant( @Param('slug') slug: string, @Body() dto: PartnerUpdateTenantDto, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.tenants.partnerUpdate(slug, actor.partnerId, dto, auditActor) } @Post('tenants/:slug/suspend') async suspendTenant( @Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.tenants.partnerSetStatus(slug, actor.partnerId, 'suspended', auditActor) } @Post('tenants/:slug/resume') async resumeTenant( @Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.tenants.partnerSetStatus(slug, actor.partnerId, 'active', auditActor) } // Monthly Recurring Revenue across the partner's customers — grouped by // currency since subs can be billed in DKK / EUR / USD independently. @Get('mrr') async mrr(@CurrentUser() jwt: AuthentikJwtPayload) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } return this.users.partnerMrr(actor.partnerId) } // Recent audit events across the partner's portfolio. Used by the // dashboard's Activity card and the /partner/audit page. Pagination via // ?before= + ?limit=N. @Get('activity') async activity( @CurrentUser() jwt: AuthentikJwtPayload, @Query('limit') limit?: string, @Query('before') before?: string, ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const partner = await this.partnerModel.findById(actor.partnerId, { slug: 1 }).exec() if (!partner) { throw new ForbiddenException('Partner record missing') } return this.users.partnerActivity(actor.partnerId, partner.slug, { limit: limit ? Number(limit) : undefined, before: before ? new Date(before) : undefined, }) } // Partner settings — profile, notification prefs, agreement (read-only), // documents (read-only), plus marginPct/contactInfo/billingInfo for display. @Get('settings') async getSettings(@CurrentUser() jwt: AuthentikJwtPayload) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } return this.partners.getSettings(actor.partnerId) } @Patch('settings') async updateSettings( @Body() dto: PartnerSettingsDto, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.partners.updateSettings(actor.partnerId, dto, auditActor) } // Whitelabel branding — identity, customer defaults, email templates. @Get('branding') async getBranding(@CurrentUser() jwt: AuthentikJwtPayload) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } return this.branding.get(actor.partnerId) } @Put('branding') async putBranding( @Body() dto: PartnerBrandingDto, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.branding.put(actor.partnerId, dto, auditActor) } // Live analytics for the reports page (health cohorts, revenue by plan, top // customers, signup/churn cohorts). Computed, not stored. @Get('reports') async getReports(@CurrentUser() jwt: AuthentikJwtPayload) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } return this.users.partnerReports(actor.partnerId) } // Saved/custom report definitions. @Get('reports/saved') async listSavedReports(@CurrentUser() jwt: AuthentikJwtPayload) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } return this.reports.list(actor.partnerId) } @Post('reports/saved') async createSavedReport( @Body() dto: CreateReportDto, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.reports.create(actor.partnerId, dto, auditActor) } @Delete('reports/saved/:id') async deleteSavedReport( @Param('id') id: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters[0], ) { const actor = await this.actor.resolve(jwt) if (!actor.partnerId) { throw new ForbiddenException('Not a partner-staff user') } const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) } return this.reports.remove(id, actor.partnerId, auditActor) } }