From 692b514fe94395c4a8573ef4fcb13001fd9b4fcf Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 14:25:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20domaine=20Plan=20+=20PlanStep=20+?= =?UTF-8?q?=20provisioning=20des=204=20plans=20pr=C3=A9-fournis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../app/controllers/new_account_controller.ts | 7 +- apps/api/app/controllers/plans_controller.ts | 134 ++++++++++++ apps/api/app/models/plan.ts | 13 ++ apps/api/app/models/plan_step.ts | 9 + apps/api/app/services/default_plans.ts | 205 ++++++++++++++++++ apps/api/app/transformers/plan_transformer.ts | 34 +++ apps/api/app/validators/plan.ts | 26 +++ .../1778080000300_create_plans_table.ts | 34 +++ .../1778080000400_create_plan_steps_table.ts | 46 ++++ apps/api/database/schema.ts | 46 ++++ apps/api/database/schema_rules.ts | 16 +- apps/api/start/routes.ts | 14 ++ 12 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 apps/api/app/controllers/plans_controller.ts create mode 100644 apps/api/app/models/plan.ts create mode 100644 apps/api/app/models/plan_step.ts create mode 100644 apps/api/app/services/default_plans.ts create mode 100644 apps/api/app/transformers/plan_transformer.ts create mode 100644 apps/api/app/validators/plan.ts create mode 100644 apps/api/database/migrations/1778080000300_create_plans_table.ts create mode 100644 apps/api/database/migrations/1778080000400_create_plan_steps_table.ts diff --git a/apps/api/app/controllers/new_account_controller.ts b/apps/api/app/controllers/new_account_controller.ts index 5c8cc62..442c66f 100644 --- a/apps/api/app/controllers/new_account_controller.ts +++ b/apps/api/app/controllers/new_account_controller.ts @@ -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 } diff --git a/apps/api/app/controllers/plans_controller.ts b/apps/api/app/controllers/plans_controller.ts new file mode 100644 index 0000000..ad22dd1 --- /dev/null +++ b/apps/api/app/controllers/plans_controller.ts @@ -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> { + const map = new Map() + 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) }) + } +} diff --git a/apps/api/app/models/plan.ts b/apps/api/app/models/plan.ts new file mode 100644 index 0000000..b473b2d --- /dev/null +++ b/apps/api/app/models/plan.ts @@ -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 + + @hasMany(() => PlanStep, { foreignKey: 'planId' }) + declare steps: HasMany +} diff --git a/apps/api/app/models/plan_step.ts b/apps/api/app/models/plan_step.ts new file mode 100644 index 0000000..82b6d77 --- /dev/null +++ b/apps/api/app/models/plan_step.ts @@ -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 +} diff --git a/apps/api/app/services/default_plans.ts b/apps/api/app/services/default_plans.ts new file mode 100644 index 0000000..6708457 --- /dev/null +++ b/apps/api/app/services/default_plans.ts @@ -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 { + 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 +} diff --git a/apps/api/app/transformers/plan_transformer.ts b/apps/api/app/transformers/plan_transformer.ts new file mode 100644 index 0000000..6837cf7 --- /dev/null +++ b/apps/api/app/transformers/plan_transformer.ts @@ -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 { + 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()!, + } + } +} diff --git a/apps/api/app/validators/plan.ts b/apps/api/app/validators/plan.ts new file mode 100644 index 0000000..7a3e0e0 --- /dev/null +++ b/apps/api/app/validators/plan.ts @@ -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(), +}) diff --git a/apps/api/database/migrations/1778080000300_create_plans_table.ts b/apps/api/database/migrations/1778080000300_create_plans_table.ts new file mode 100644 index 0000000..91f00b0 --- /dev/null +++ b/apps/api/database/migrations/1778080000300_create_plans_table.ts @@ -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) + } +} diff --git a/apps/api/database/migrations/1778080000400_create_plan_steps_table.ts b/apps/api/database/migrations/1778080000400_create_plan_steps_table.ts new file mode 100644 index 0000000..334b1ad --- /dev/null +++ b/apps/api/database/migrations/1778080000400_create_plan_steps_table.ts @@ -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') + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index f40ff1e..4a64756 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -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 diff --git a/apps/api/database/schema_rules.ts b/apps/api/database/schema_rules.ts index 1153c92..42e0531 100644 --- a/apps/api/database/schema_rules.ts +++ b/apps/api/database/schema_rules.ts @@ -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 diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index c4c692a..3b965bd 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -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')