rubis/apps/api/app/controllers/plans_controller.ts
ordinarthur 9e531e32a9 feat(plans): wizard de création de plan custom + génération IA Mistral
Backend
- migration : champs contact_first_name / contact_last_name (nullable)
  sur clients pour personnaliser les variables de relance
- POST /api/v1/plans : création de plan custom avec slug auto-généré
  (suffixé en cas de collision, "nouveau"/"new"/"create" réservés)
- POST /api/v1/ai/generate-relance : génération de subject+body via
  mistral-small-latest, avec brief utilisateur et tonalité ciblée
- mail_dispatcher : nouvelles variables {{daysLate}}, {{issueDate}},
  {{user.fullName}}, {{user.companyName}}, {{client.contactFirstName}},
  {{client.contactLastName}} (helper buildRelanceVars exposé pour preview)
- send_relance_job preload désormais l'organization pour exposer son name

Frontend
- /plans/nouveau : wizard 4 étapes (Identité → Cadence → Messages → Récap)
  - Stepper en haut, navigation guidée, validation par étape
  - Étape 1 : nom + tonalité globale (4 cards Doux/Standard/Ferme/Strict)
    avec aperçu de la cadence par défaut associée
  - Étape 2 : timeline horizontale (rail rubis-glow + nœuds ◆ teintés
    selon la tonalité), édition décalage/ton de l'étape sélectionnée
  - Étape 3 : édition par étape avec preview live à droite, chips de
    variables cliquables, bouton "Générer avec l'IA" qui ouvre une modale
    Mistral (brief + résultat + régénérer)
  - Étape 4 : récap avec preview de chaque email rendu sur un client fictif
- Détection des variables sensibles → warning si X clients existants n'ont
  pas le champ contactFirstName/contactLastName rempli (UX informative,
  fallback vide à l'envoi)
- "Dupliquer" sur chaque card de plan → /plans/nouveau?from=<slug>
  pour pré-remplir le wizard à partir d'un plan existant
- ClientCreateDialog : ajout des champs prénom/nom du contact dédié
- TEMPLATE_VARIABLES étendu, helper renderTemplate côté front en miroir
  exact de l'implémentation API
- MSW handlers ai/plans/clients alignés sur le nouveau contrat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:55:00 +02:00

229 lines
7.0 KiB
TypeScript

import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
import PlanTransformer from '#transformers/plan_transformer'
import { createPlanValidator, updatePlanValidator } from '#validators/plan'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
/**
* Slug à partir d'un nom de plan : minuscules, ASCII safe, tirets.
* On garantit l'unicité par org en suffixant un compteur si collision.
*/
function slugify(input: string): string {
return input
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'plan'
}
// Slugs réservés côté front (routes statiques type /plans/nouveau).
// Si l'utilisateur nomme son plan "nouveau", on suffixe d'office.
const RESERVED_SLUGS = new Set(['nouveau', 'new', 'create'])
async function nextAvailableSlug(organizationId: string, base: string): Promise<string> {
const start = RESERVED_SLUGS.has(base) ? `${base}-1` : base
const existing = await Plan.query()
.where('organization_id', organizationId)
.whereILike('slug', `${base}%`)
.select('slug')
const taken = new Set(existing.map((p) => p.slug))
if (!taken.has(start) && !RESERVED_SLUGS.has(start)) return start
for (let i = 2; i < 100; i++) {
const candidate = `${base}-${i}`
if (!taken.has(candidate) && !RESERVED_SLUGS.has(candidate)) return candidate
}
return `${base}-${Date.now()}`
}
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
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 (non payées, non annulées) référencent
* chaque plan d'une org. Utilisé pour enrichir la liste avec un badge "X
* factures utilisent ce plan" — utile avant édition pour signaler l'impact.
*/
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)
if (planIds.length === 0) return map
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.whereIn('plan_id', planIds)
.whereRaw(`status::text in ${ACTIVE_INVOICE_STATUSES}`)
.select('plan_id')
.count('* as count')
.groupBy('plan_id')
for (const r of rows) {
map.set(r.plan_id, Number(r.count))
}
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) })
}
/**
* POST /plans — création d'un plan custom.
*
* Slug auto-généré depuis `name`, suffixé en cas de collision dans l'org.
* Le plan custom n'est pas marqué `isDefault` — il peut être supprimé
* (V2) sans toucher à la bibliothèque.
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createPlanValidator)
const baseSlug = slugify(payload.name)
const slug = await nextAvailableSlug(organizationId, baseSlug)
const plan = await db.transaction(async (trx) => {
const created = await Plan.create(
{
organizationId,
slug,
name: payload.name,
description: payload.description ?? '',
isDefault: false,
},
{ client: trx }
)
await PlanStep.createMany(
payload.steps.map((s) => ({
planId: created.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
return created
})
await plan.load('steps')
return response.status(201).json({ data: serializePlan(plan) })
}
}