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:
parent
1d3b6a3f8f
commit
b6006ad1f7
170
apps/api/app/controllers/clients_controller.ts
Normal file
170
apps/api/app/controllers/clients_controller.ts
Normal 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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/app/models/client.ts
Normal file
11
apps/api/app/models/client.ts
Normal 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.
|
||||||
|
}
|
||||||
43
apps/api/app/services/client_stats.ts
Normal file
43
apps/api/app/services/client_stats.ts
Normal 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
|
||||||
|
}
|
||||||
20
apps/api/app/transformers/client_transformer.ts
Normal file
20
apps/api/app/transformers/client_transformer.ts
Normal 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()!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/api/app/validators/client.ts
Normal file
34
apps/api/app/validators/client.ts
Normal 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(),
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,31 @@ export class AuthAccessTokenSchema extends BaseModel {
|
|||||||
declare updatedAt: DateTime | null
|
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 {
|
export class OrganizationSchema extends BaseModel {
|
||||||
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
|
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
|
||||||
$columns = OrganizationSchema.$columns
|
$columns = OrganizationSchema.$columns
|
||||||
|
|||||||
@ -54,5 +54,19 @@ router
|
|||||||
.prefix('organizations')
|
.prefix('organizations')
|
||||||
.as('organizations')
|
.as('organizations')
|
||||||
.use(middleware.auth())
|
.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')
|
.prefix('/api/v1')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user