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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user