feat(api): domaine Client + CRUD /api/v1/clients

Migration clients (uuid id, organization_id FK uuid CASCADE, name, email REQUIS, phone, address, siret, notes). Index sur organization_id.

Modèle Client avec belongsTo Organization. La relation hasMany Invoice est volontairement omise tant que le domaine Invoice n'est pas câblé.

Validators Vine alignés sur le contrat MSW :
- create : name 2-120, email requis avec format, siret 14 chiffres si fourni
- update : tout optionnel
- email REQUIS au create — pivot produit, pas de relance possible sans

Endpoints (auth requise, scopés par organizationId du user courant) :
- GET /clients?withStats=1&q= : liste filtrée + recherche, enrichissement stats optionnel, tri par actionnabilité (retards d'abord) quand withStats
- GET /clients/:id : détail (id en UUID via router.matchers.uuid())
- POST /clients : 201 + détection doublon par nom case-insensitive → 409 avec payload `existing` (le SPA peut proposer "voir le client existant")
- PATCH /clients/:id : merge partiel

Service ClientStats avec interface bulkComputeClientStats() qui retourne EMPTY pour l'instant — sera vraiment branché quand Invoice arrive. Le contrat reste stable côté SPA, juste les compteurs à 0.

Sérialisation : pour les listes avec stats per-item, on instancie le transformer manuellement (`new ClientTransformer(c).toObject()`) plutôt que de passer par BaseTransformer.transform() qui retourne un Item nested non-unwrappable hors clé directe de serialize().
This commit is contained in:
ordinarthur 2026-05-06 14:13:13 +02:00
parent 1d3b6a3f8f
commit b6006ad1f7
8 changed files with 352 additions and 0 deletions

View File

@ -0,0 +1,170 @@
import Client from '#models/client'
import ClientTransformer from '#transformers/client_transformer'
import { createClientValidator, updateClientValidator } from '#validators/client'
import { bulkComputeClientStats } from '#services/client_stats'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
/**
* Petite cohérence d'identification orgnisation : si l'utilisateur
* n'en a pas, on est dans un état illégal V1 on bloque ferme.
*/
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
}
/**
* Sérialisation directe (instanciation manuelle du transformer pour
* éviter le wrapper Item utile quand on doit fusionner des stats
* computed par-dessus chaque client dans une liste).
*/
function serializeClient(c: Client) {
return new ClientTransformer(c).toObject()
}
export default class ClientsController {
/**
* GET /clients?withStats=1&q=
*
* Sans `withStats`, retour à plat (utilisé par le combobox de saisie).
* Avec `withStats`, chaque client est enrichi des compteurs de factures
* et trié par actionnabilité (retards d'abord, puis activité récente).
*/
async index({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const withStats = request.input('withStats') === '1'
const q = (request.input('q') ?? '').toString().trim().toLowerCase()
const query = Client.query().where('organization_id', organizationId)
if (q.length > 0) {
query.where((b) => {
b.whereILike('name', `%${q}%`).orWhereILike('email', `%${q}%`)
})
}
const clients = await query.exec()
if (!withStats) {
// Tri alphabétique par défaut pour le combobox.
clients.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
return response.json({ data: clients.map(serializeClient) })
}
const statsMap = await bulkComputeClientStats(
organizationId,
clients.map((c) => c.id)
)
const enriched = clients.map((c) => ({
...serializeClient(c),
...statsMap.get(c.id)!,
}))
// Tri actionnable : retards d'abord, puis activité récente.
enriched.sort((a, b) => {
if (a.lateInvoiceCount !== b.lateInvoiceCount) {
return b.lateInvoiceCount - a.lateInvoiceCount
}
const aLast = a.lastActivityAt ?? ''
const bLast = b.lastActivityAt ?? ''
return bLast.localeCompare(aLast)
})
return response.json({ data: enriched })
}
/**
* GET /clients/:id détail enrichi (stats + invoices à venir).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!client) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
const statsMap = await bulkComputeClientStats(organizationId, [client.id])
const stats = statsMap.get(client.id)!
return response.json({
data: {
...serializeClient(client),
...stats,
invoices: [], // TODO: brancher quand le domaine Invoice arrive
},
})
}
/**
* POST /clients création manuelle.
* Détecte les doublons de nom (case-insensitive) et renvoie 409 avec
* la fiche existante pour permettre au SPA de proposer "voir le client".
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createClientValidator)
// Doublon → 409 (cf. clients.ts MSW pour le contrat exact).
const existing = await Client.query()
.where('organization_id', organizationId)
.whereILike('name', payload.name)
.first()
if (existing) {
return response.status(409).json({
errors: [
{
code: 'duplicate_client',
message: `Un client nommé « ${existing.name} » existe déjà.`,
field: 'name',
},
],
existing: serializeClient(existing),
})
}
const created = await Client.create({
organizationId,
name: payload.name,
email: payload.email,
phone: payload.phone ?? null,
address: payload.address ?? null,
siret: payload.siret ?? null,
notes: payload.notes ?? null,
})
return response.status(201).json({ data: serializeClient(created) })
}
/**
* PATCH /clients/:id édition partielle.
*/
async update({ auth, request, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(updateClientValidator)
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!client) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
client.merge(payload)
await client.save()
return response.json({ data: serializeClient(client) })
}
}

View File

@ -0,0 +1,11 @@
import { ClientSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
export default class Client extends ClientSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
// hasMany Invoice — sera ajouté quand le domaine Invoice arrivera.
}

View File

@ -0,0 +1,43 @@
/**
* Stats agrégées d'un client. Calculées on-the-fly à partir des invoices
* (V1 : pas de cache, le volume reste raisonnable).
*
* Tant que le domaine Invoice n'est pas câblé, on retourne EMPTY pour
* tous les clients le contrat reste stable côté SPA.
*/
export type ClientStats = {
invoiceCount: number
activeInvoiceCount: number
lateInvoiceCount: number
paidInvoiceCount: number
paidLifetimeCents: number
pendingLifetimeCents: number
lastActivityAt: string | null
}
export const EMPTY_CLIENT_STATS: ClientStats = {
invoiceCount: 0,
activeInvoiceCount: 0,
lateInvoiceCount: 0,
paidInvoiceCount: 0,
paidLifetimeCents: 0,
pendingLifetimeCents: 0,
lastActivityAt: null,
}
/**
* Calcule les stats pour un ensemble de clients d'une org.
* @returns Map clientId ClientStats
*
* @todo Implémenter quand Invoice arrive pour l'instant tout le monde a 0.
*/
export async function bulkComputeClientStats(
_organizationId: string,
clientIds: string[]
): Promise<Map<string, ClientStats>> {
const map = new Map<string, ClientStats>()
for (const id of clientIds) {
map.set(id, EMPTY_CLIENT_STATS)
}
return map
}

View File

@ -0,0 +1,20 @@
import type Client from '#models/client'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class ClientTransformer extends BaseTransformer<Client> {
toObject() {
const c = this.resource
return {
id: c.id,
organizationId: c.organizationId,
name: c.name,
email: c.email,
phone: c.phone,
address: c.address,
siret: c.siret,
notes: c.notes,
createdAt: c.createdAt.toISO()!,
updatedAt: c.updatedAt?.toISO() ?? c.createdAt.toISO()!,
}
}
}

View File

@ -0,0 +1,34 @@
import vine from '@vinejs/vine'
const name = () => vine.string().minLength(2).maxLength(120)
const email = () => vine.string().email().maxLength(254)
// SIRET = 14 chiffres exactement (cf. INSEE).
const siret = () => vine.string().regex(/^\d{14}$/)
const phone = () => vine.string().maxLength(40)
const address = () => vine.string().maxLength(500)
const notes = () => vine.string().maxLength(2000)
/**
* Validator pour POST /clients. Email **requis** : sans email, Rubis ne
* peut pas relancer (pivot produit, cf. CLAUDE.md Principes).
*/
export const createClientValidator = vine.create({
name: name(),
email: email(),
phone: phone().nullable().optional(),
address: address().nullable().optional(),
siret: siret().nullable().optional(),
notes: notes().nullable().optional(),
})
/**
* Validator pour PATCH /clients/:id. Tous les champs optionnels.
*/
export const updateClientValidator = vine.create({
name: name().optional(),
email: email().optional(),
phone: phone().nullable().optional(),
address: address().nullable().optional(),
siret: siret().nullable().optional(),
notes: notes().nullable().optional(),
})

View File

@ -0,0 +1,35 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'clients'
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')
table.string('name', 120).notNullable()
// Email REQUIS : sans email, pas de relance possible. Pivot produit.
table.string('email', 254).notNullable()
table.string('phone', 40).nullable()
table.string('address', 500).nullable()
table.string('siret', 14).nullable()
table.text('notes').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
// Index pour les listings + recherche par org
table.index(['organization_id'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -32,6 +32,31 @@ export class AuthAccessTokenSchema extends BaseModel {
declare updatedAt: DateTime | null
}
export class ClientSchema extends BaseModel {
static $columns = ['address', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const
$columns = ClientSchema.$columns
@column()
declare address: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare email: string
@column({ isPrimary: true })
declare id: string
@column()
declare name: string
@column()
declare notes: string | null
@column()
declare organizationId: string
@column()
declare phone: string | null
@column()
declare siret: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class OrganizationSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
$columns = OrganizationSchema.$columns

View File

@ -54,5 +54,19 @@ router
.prefix('organizations')
.as('organizations')
.use(middleware.auth())
/**
* Clients auth requise, scope par organization de l'utilisateur courant.
*/
router
.group(() => {
router.get('', [controllers.Clients, 'index']).as('index')
router.post('', [controllers.Clients, 'store']).as('store')
router.get(':id', [controllers.Clients, 'show']).as('show').where('id', router.matchers.uuid())
router.patch(':id', [controllers.Clients, 'update']).as('update').where('id', router.matchers.uuid())
})
.prefix('clients')
.as('clients')
.use(middleware.auth())
})
.prefix('/api/v1')