rubis/apps/api/app/controllers/clients_controller.ts
ordinarthur b6006ad1f7 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().
2026-05-06 14:13:13 +02:00

171 lines
5.2 KiB
TypeScript

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) })
}
}