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).
This commit is contained in:
ordinarthur 2026-05-06 14:25:06 +02:00
parent b6006ad1f7
commit 692b514fe9
12 changed files with 581 additions and 3 deletions

View File

@ -6,6 +6,7 @@ import UserTransformer from '#transformers/user_transformer'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import { DateTime } from 'luxon'
import { provisionDefaultPlans } from '#services/default_plans'
export default class NewAccountController {
/**
@ -19,10 +20,12 @@ export default class NewAccountController {
async store({ request, response, serialize }: HttpContext) {
const { fullName, email, password } = await request.validateUsing(signupValidator)
// org + user créés atomiquement, puis le token (qui passe par un client
// pg séparé via DbAccessTokensProvider — il doit voir l'user commit).
// org + user + 4 plans pré-fournis créés atomiquement, puis le token
// (qui passe par un client pg séparé via DbAccessTokensProvider — il
// doit voir l'user commit).
const user = await db.transaction(async (trx) => {
const org = await Organization.create({ name: '' }, { client: trx })
await provisionDefaultPlans(org.id, trx)
return User.create(
{ email, password, fullName, organizationId: org.id },
{ client: trx }

View File

@ -0,0 +1,134 @@
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) })
}
}

View File

@ -0,0 +1,13 @@
import { PlanSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import PlanStep from '#models/plan_step'
export default class Plan extends PlanSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => PlanStep, { foreignKey: 'planId' })
declare steps: HasMany<typeof PlanStep>
}

View File

@ -0,0 +1,9 @@
import { PlanStepSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Plan from '#models/plan'
export default class PlanStep extends PlanStepSchema {
@belongsTo(() => Plan)
declare plan: BelongsTo<typeof Plan>
}

View File

@ -0,0 +1,205 @@
/**
* Source de vérité des 4 plans pré-fournis (cf. CLAUDE.md Périmètre V1).
* Dupliqués dans chaque organisation à la création (signup) V1 mono-tenant
* mais l'isolation est totale, on peut éditer le plan d'une org sans toucher
* aux autres.
*
* Les valeurs (cadences, tons, sujets) doivent rester alignées sur le seed
* MSW (apps/web/src/mocks/seed.ts SEED_PLANS) tant que les deux coexistent.
*/
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
type DefaultStep = {
order: number
offsetDays: number
tone: 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
subject: string
body: string
requiresManualValidation: boolean
}
type DefaultPlan = {
slug: string
name: string
description: string
steps: DefaultStep[]
}
export const DEFAULT_PLANS: DefaultPlan[] = [
{
slug: 'standard-30j',
name: 'Standard B2B',
description:
'Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.',
steps: [
{
order: 0,
offsetDays: 3,
tone: 'amical',
subject: 'Petit rappel — facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nNous espérons que tout va bien. Un petit rappel concernant la facture {{numero}} d'un montant de {{amount}}, échue le {{dueDate}}.\n\nMerci d'avance,\n{{signature}}",
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 10,
tone: 'courtois',
subject: 'Relance — facture {{numero}} en retard',
body:
"Bonjour {{client.name}},\n\nSauf erreur de notre part, la facture {{numero}} d'un montant de {{amount}} reste impayée.\n\nMerci de procéder au règlement dans les meilleurs délais.\n\n{{signature}}",
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 25,
tone: 'ferme',
subject: 'Mise en demeure — facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nMalgré nos relances, la facture {{numero}} d'un montant de {{amount}} reste impayée. Nous vous mettons en demeure de régler sous 8 jours.\n\n{{signature}}",
requiresManualValidation: true,
},
],
},
{
slug: 'rapide-15j',
name: 'Rapide',
description: 'Cadence resserrée pour les factures récurrentes ou les délais courts.',
steps: [
{
order: 0,
offsetDays: 1,
tone: 'amical',
subject: 'Facture {{numero}} échue',
body: 'Bonjour, petit rappel pour la facture {{numero}}.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 7,
tone: 'courtois',
subject: 'Relance facture {{numero}}',
body: 'La facture {{numero}} reste impayée à ce jour. Merci de régulariser.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 15,
tone: 'ferme',
subject: 'Mise en demeure {{numero}}',
body: 'Mise en demeure formelle de payer sous 8 jours.\n\n{{signature}}',
requiresManualValidation: true,
},
],
},
{
slug: 'patient-60j',
name: 'Patient',
description: 'Pour les clients de longue date. On laisse respirer avant de relancer.',
steps: [
{
order: 0,
offsetDays: 15,
tone: 'amical',
subject: 'Facture {{numero}}',
body: 'Bonjour, simple rappel.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 30,
tone: 'courtois',
subject: 'Relance facture {{numero}}',
body: 'Merci de régulariser dans les meilleurs délais.\n\n{{signature}}',
requiresManualValidation: false,
},
],
},
{
slug: 'ferme-7j',
name: 'Ferme',
description: 'Cadence stricte pour les clients à risque ou les retards récurrents.',
steps: [
{
order: 0,
offsetDays: 1,
tone: 'courtois',
subject: 'Facture {{numero}}',
body: 'Premier rappel.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 5,
tone: 'ferme',
subject: 'Relance ferme {{numero}}',
body: 'Le règlement est attendu sous 48h.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 10,
tone: 'mise_en_demeure',
subject: 'Mise en demeure {{numero}}',
body: 'Mise en demeure formelle.\n\n{{signature}}',
requiresManualValidation: true,
},
],
},
]
/**
* Provisionne les 4 plans par défaut pour une organisation fraîchement créée.
* Idempotent : si l'org a déjà un plan avec un slug, on n'écrase pas.
*
* À appeler dans la transaction de signup.
*/
export async function provisionDefaultPlans(
organizationId: string,
trx: TransactionClientContract
): Promise<Plan[]> {
const existing = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.whereIn(
'slug',
DEFAULT_PLANS.map((p) => p.slug)
)
.select('slug')
const existingSlugs = new Set(existing.map((p) => p.slug))
const created: Plan[] = []
for (const tpl of DEFAULT_PLANS) {
if (existingSlugs.has(tpl.slug)) continue
const plan = await Plan.create(
{
organizationId,
slug: tpl.slug,
name: tpl.name,
description: tpl.description,
isDefault: true,
},
{ client: trx }
)
await PlanStep.createMany(
tpl.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 }
)
created.push(plan)
}
return created
}

View File

@ -0,0 +1,34 @@
import type Plan from '#models/plan'
import type PlanStep from '#models/plan_step'
import { BaseTransformer } from '@adonisjs/core/transformers'
function serializeStep(s: PlanStep) {
return {
id: s.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
}
}
export default class PlanTransformer extends BaseTransformer<Plan> {
toObject() {
const p = this.resource
// p.steps doit être préchargé par le controller (preload('steps'))
const steps = (p.steps ?? []).slice().sort((a, b) => a.order - b.order)
return {
id: p.id,
organizationId: p.organizationId,
slug: p.slug,
name: p.name,
description: p.description,
isDefault: p.isDefault,
steps: steps.map(serializeStep),
createdAt: p.createdAt.toISO()!,
updatedAt: p.updatedAt?.toISO() ?? p.createdAt.toISO()!,
}
}
}

View File

@ -0,0 +1,26 @@
import vine from '@vinejs/vine'
const RELANCE_TONES = ['amical', 'courtois', 'ferme', 'mise_en_demeure'] as const
const planStep = vine.object({
// id optionnel : présent si on édite une étape existante, absent pour
// une création (le contrôleur le générera).
id: vine.string().optional(),
order: vine.number().min(0),
// Plage : -30 (rappel avant échéance) à 180 jours (gros retards).
offsetDays: vine.number().min(-30).max(180),
tone: vine.enum(RELANCE_TONES),
subject: vine.string().minLength(1).maxLength(200),
body: vine.string().minLength(1).maxLength(5000),
requiresManualValidation: vine.boolean(),
})
/**
* Validator pour PATCH /plans/:slug. Tous les champs optionnels l'éditeur
* front peut envoyer juste `name` ou juste `steps` selon ce qu'il modifie.
*/
export const updatePlanValidator = vine.create({
name: vine.string().minLength(1).maxLength(80).optional(),
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10).optional(),
})

View File

@ -0,0 +1,34 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'plans'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table
.uuid('organization_id')
.notNullable()
.references('id')
.inTable('organizations')
.onDelete('CASCADE')
// Slug stable pour les 4 plans pré-fournis (standard-30j, rapide-15j…).
// null pour les plans custom (V2). On indexe pour le lookup par slug.
table.string('slug', 60).nullable()
table.string('name', 80).notNullable()
table.string('description', 500).notNullable().defaultTo('')
table.boolean('is_default').notNullable().defaultTo(false)
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
table.index(['organization_id'])
table.unique(['organization_id', 'slug']) // 1 slug max par org
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,46 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'plan_steps'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table
.uuid('plan_id')
.notNullable()
.references('id')
.inTable('plans')
.onDelete('CASCADE')
// Position dans le plan (0-indexed, monotone croissante).
table.integer('order').notNullable()
// Décalage par rapport à la date d'échéance, peut être négatif (rappel
// avant échéance). Plage raisonnable -30 à 180 jours côté validator.
table.integer('offset_days').notNullable()
// Enum check côté DB pour garantir l'intégrité même hors Vine.
table
.enum('tone', ['amical', 'courtois', 'ferme', 'mise_en_demeure'], {
useNative: true,
enumName: 'plan_step_tone',
})
.notNullable()
table.string('subject', 200).notNullable()
table.text('body').notNullable()
// Mise en demeure : pas d'envoi auto, validation manuelle par l'user
// (cf. CLAUDE.md → Principes produit).
table.boolean('requires_manual_validation').notNullable().defaultTo(false)
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
table.index(['plan_id', 'order'])
})
}
async down() {
this.schema.dropTable(this.tableName)
// Drop le type ENUM PG créé par useNative
this.schema.raw('DROP TYPE IF EXISTS plan_step_tone')
}
}

View File

@ -78,6 +78,52 @@ export class OrganizationSchema extends BaseModel {
declare updatedAt: DateTime | null
}
export class PlanStepSchema extends BaseModel {
static $columns = ['body', 'createdAt', 'id', 'offsetDays', 'order', 'planId', 'requiresManualValidation', 'subject', 'tone', 'updatedAt'] as const
$columns = PlanStepSchema.$columns
@column()
declare body: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare offsetDays: number
@column()
declare order: number
@column()
declare planId: string
@column()
declare requiresManualValidation: boolean
@column()
declare subject: string
@column()
declare tone: 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class PlanSchema extends BaseModel {
static $columns = ['createdAt', 'description', 'id', 'isDefault', 'name', 'organizationId', 'slug', 'updatedAt'] as const
$columns = PlanSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare description: string
@column({ isPrimary: true })
declare id: string
@column()
declare isDefault: boolean
@column()
declare name: string
@column()
declare organizationId: string
@column()
declare slug: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
$columns = UserSchema.$columns

View File

@ -1,3 +1,17 @@
import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator'
export default {} satisfies SchemaRules
/**
* Override de types pour les colonnes que Lucid n'arrive pas à inférer
* depuis l'introspection PG (ex. ENUMs natifs tapés `any` par défaut).
*/
export default {
tables: {
plan_steps: {
columns: {
tone: {
tsType: "'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'",
},
},
},
},
} satisfies SchemaRules

View File

@ -68,5 +68,19 @@ router
.prefix('clients')
.as('clients')
.use(middleware.auth())
/**
* Plans auth requise. Lookup par slug (stable et lisible :
* /parametres/plans/standard-30j). POST plan custom = V2.
*/
router
.group(() => {
router.get('', [controllers.Plans, 'index']).as('index')
router.get(':slug', [controllers.Plans, 'show']).as('show')
router.patch(':slug', [controllers.Plans, 'update']).as('update')
})
.prefix('plans')
.as('plans')
.use(middleware.auth())
})
.prefix('/api/v1')