diff --git a/apps/api/app/controllers/clients_controller.ts b/apps/api/app/controllers/clients_controller.ts new file mode 100644 index 0000000..b1dc7e5 --- /dev/null +++ b/apps/api/app/controllers/clients_controller.ts @@ -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) }) + } +} diff --git a/apps/api/app/models/client.ts b/apps/api/app/models/client.ts new file mode 100644 index 0000000..22db4fb --- /dev/null +++ b/apps/api/app/models/client.ts @@ -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 + + // hasMany Invoice — sera ajouté quand le domaine Invoice arrivera. +} diff --git a/apps/api/app/services/client_stats.ts b/apps/api/app/services/client_stats.ts new file mode 100644 index 0000000..349bb59 --- /dev/null +++ b/apps/api/app/services/client_stats.ts @@ -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> { + const map = new Map() + for (const id of clientIds) { + map.set(id, EMPTY_CLIENT_STATS) + } + return map +} diff --git a/apps/api/app/transformers/client_transformer.ts b/apps/api/app/transformers/client_transformer.ts new file mode 100644 index 0000000..e573ab8 --- /dev/null +++ b/apps/api/app/transformers/client_transformer.ts @@ -0,0 +1,20 @@ +import type Client from '#models/client' +import { BaseTransformer } from '@adonisjs/core/transformers' + +export default class ClientTransformer extends BaseTransformer { + 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()!, + } + } +} diff --git a/apps/api/app/validators/client.ts b/apps/api/app/validators/client.ts new file mode 100644 index 0000000..d294536 --- /dev/null +++ b/apps/api/app/validators/client.ts @@ -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(), +}) diff --git a/apps/api/database/migrations/1778080000200_create_clients_table.ts b/apps/api/database/migrations/1778080000200_create_clients_table.ts new file mode 100644 index 0000000..822cbdb --- /dev/null +++ b/apps/api/database/migrations/1778080000200_create_clients_table.ts @@ -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) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index eb3487e..f40ff1e 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -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 diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 5d9564b..c4c692a 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -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')