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:
parent
b6006ad1f7
commit
692b514fe9
@ -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 }
|
||||
|
||||
134
apps/api/app/controllers/plans_controller.ts
Normal file
134
apps/api/app/controllers/plans_controller.ts
Normal 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) })
|
||||
}
|
||||
}
|
||||
13
apps/api/app/models/plan.ts
Normal file
13
apps/api/app/models/plan.ts
Normal 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>
|
||||
}
|
||||
9
apps/api/app/models/plan_step.ts
Normal file
9
apps/api/app/models/plan_step.ts
Normal 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>
|
||||
}
|
||||
205
apps/api/app/services/default_plans.ts
Normal file
205
apps/api/app/services/default_plans.ts
Normal 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
|
||||
}
|
||||
34
apps/api/app/transformers/plan_transformer.ts
Normal file
34
apps/api/app/transformers/plan_transformer.ts
Normal 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()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/api/app/validators/plan.ts
Normal file
26
apps/api/app/validators/plan.ts
Normal 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(),
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user