rubis/apps/api/app/controllers/plans_controller.ts
ordinarthur 692b514fe9 feat(api): domaine Plan + PlanStep + provisioning des 4 plans pré-fournis
Migrations :
- plans (uuid id, organization_id FK CASCADE, slug nullable, name, description, is_default). Unique (organization_id, slug) — un slug max par org.
- plan_steps (uuid id, plan_id FK CASCADE, order, offset_days, tone ENUM PG natif, subject, body, requires_manual_validation).

Schema rules : override du tone (introspection PG → 'any', on précise l'union).

Modèles Plan (belongsTo Organization, hasMany PlanStep) et PlanStep (belongsTo Plan).

Décision : plans dupliqués par organisation au signup (pas de table globale partagée). Permet l'édition isolée par org sans toucher aux templates des autres tenants. Le service `provisionDefaultPlans(orgId, trx)` est idempotent et appelé depuis NewAccountController dans la transaction de création.

Source de vérité des 4 plans (Standard B2B, Rapide, Patient, Ferme) dans app/services/default_plans.ts — alignée sur apps/web/src/mocks/seed.ts.

Endpoints :
- GET /plans : liste enrichie avec usageCount (à 0 tant qu'Invoice n'est pas câblé).
- GET /plans/:slug : détail (lookup par slug pour URL stable côté SPA).
- PATCH /plans/:slug : édition partielle. Les steps sont remplacés en bloc dans une transaction (pas de diff fin id-par-id, plus simple et prévisible).

POST plan custom = V2 (cf. backend.md §5.5).
2026-05-06 14:25:06 +02:00

135 lines
4.1 KiB
TypeScript

import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
import PlanTransformer from '#transformers/plan_transformer'
import { updatePlanValidator } from '#validators/plan'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
function serializePlan(p: Plan) {
return new PlanTransformer(p).toObject()
}
/**
* Compte combien de factures actives référencent chaque plan d'une org.
* Utilisé pour enrichir la liste avec un badge d'usage.
*
* @todo Brancher sur Invoice quand le domaine arrive — pour l'instant 0
* partout (le contrat reste stable côté SPA).
*/
async function bulkComputePlanUsage(
_organizationId: string,
planIds: string[]
): Promise<Map<string, number>> {
const map = new Map<string, number>()
for (const id of planIds) map.set(id, 0)
return map
}
export default class PlansController {
/**
* GET /plans — liste enrichie avec compteurs d'usage.
*/
async index({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const plans = await Plan.query()
.where('organization_id', organizationId)
.preload('steps')
.orderBy('is_default', 'desc')
.orderBy('name', 'asc')
const usage = await bulkComputePlanUsage(
organizationId,
plans.map((p) => p.id)
)
const data = plans.map((p) => ({
...serializePlan(p),
usageCount: usage.get(p.id) ?? 0,
}))
return response.json({ data })
}
/**
* GET /plans/:slug — détail.
* Le SPA lookup par slug pour les plans pré-fournis (URL stable et
* lisible : /parametres/plans/standard-30j).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const plan = await Plan.query()
.where('organization_id', organizationId)
.where('slug', params.slug)
.preload('steps')
.first()
if (!plan) {
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
}
const usage = await bulkComputePlanUsage(organizationId, [plan.id])
return response.json({
data: { ...serializePlan(plan), usageCount: usage.get(plan.id) ?? 0 },
})
}
/**
* PATCH /plans/:slug — édite nom, description et/ou recompose les étapes.
*
* Recomposition des steps : on ne fait pas de diff fin (id par id), on
* remplace tout le set en transaction. Plus simple, plus prévisible, et
* idiomatique côté UX (l'utilisateur a édité son plan dans son ensemble).
*/
async update({ auth, params, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(updatePlanValidator)
const plan = await Plan.query()
.where('organization_id', organizationId)
.where('slug', params.slug)
.first()
if (!plan) {
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
}
await db.transaction(async (trx) => {
plan.useTransaction(trx)
if (payload.name !== undefined) plan.name = payload.name
if (payload.description !== undefined) plan.description = payload.description
await plan.save()
if (payload.steps !== undefined) {
// Remplace tout le set
await PlanStep.query({ client: trx }).where('plan_id', plan.id).delete()
await PlanStep.createMany(
payload.steps.map((s) => ({
planId: plan.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
}
})
await plan.load('steps')
return response.json({ data: serializePlan(plan) })
}
}