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 { 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> { const map = new Map() 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 if (payload.thanksSubject !== undefined) plan.thanksSubject = payload.thanksSubject if (payload.thanksBody !== undefined) plan.thanksBody = payload.thanksBody 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, thanksSubject: payload.thanksSubject ?? null, thanksBody: payload.thanksBody ?? null, }, { 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) }) } }