feat(api): dashboard kpis + activity feed + top-late + ActivityEvent

Migration activity_events (uuid id, organization_id FK CASCADE, kind ENUM PG natif relance_sent/invoice_paid/invoice_imported/warning_drafted, at, label HTML léger, meta jsonb). Append-only — pas de mutation. Index (org, at).

Schema rules : kind typé en union + meta typé { invoiceId?, clientId?, planStepOrder? }.

Service activity_recorder.ts : recordActivity({orgId, kind, label, meta, trx?}). Branché dans :
- InvoicesController.markPaid → invoice_paid
- ImportBatchesController.validateDraft → invoice_imported
À venir : SendRelanceJob (relance_sent + warning_drafted) quand BullMQ sera là.

Service dashboard.ts :
- computeKpis(orgId) : 1 requête FILTER pour les counts par status + 1 requête pour les sommes paid this month / prev month / DSO. miseEnDemeurePending=0 et percentile=undefined V1 (placeholders honnêtes plutôt que faux chiffres).
- topLatePayers(orgId, 5) : INNER JOIN clients + agrégation count() par client_id, due_date < today + status actif.

Controller DashboardController :
- GET /dashboard/kpis : computeKpis
- GET /dashboard/activity : 20 derniers events de l'org, plus récent en tête
- GET /dashboard/top-late : top 5

Routes /api/v1/dashboard/* (auth requise).

Bruno : nouveau dossier 07-Dashboard avec 3 requêtes documentées.

Pour générer du contenu activity feed : encaisser une facture (Invoices → Mark paid) ou valider un draft (Imports → Validate). KPIs : créer des factures puis les marquer payées (paidAt rentre dans les sommes).
This commit is contained in:
ordinarthur 2026-05-06 15:10:58 +02:00
parent 5d3408fafa
commit 704f472729
14 changed files with 527 additions and 0 deletions

View File

@ -0,0 +1,65 @@
import ActivityEvent from '#models/activity_event'
import { computeKpis, topLatePayers } from '#services/dashboard'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
const ACTIVITY_DEFAULT_LIMIT = 20
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
}
export default class DashboardController {
/**
* GET /dashboard/kpis
*
* Cf. service dashboard.ts quelques metrics V1 sont placeholder
* (miseEnDemeurePending=0 tant que RelanceTask pas branché, percentile
* undefined tant que cohorte trop petite).
*/
async kpis({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const data = await computeKpis(organizationId)
return response.json({ data })
}
/**
* GET /dashboard/activity
*
* Journal append-only. Limit 20 par défaut, plus récent en tête.
*/
async activity({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const events = await ActivityEvent.query()
.where('organization_id', organizationId)
.orderBy('at', 'desc')
.limit(ACTIVITY_DEFAULT_LIMIT)
return response.json({
data: events.map((e) => ({
id: e.id,
kind: e.kind,
at: e.at.toISO()!,
label: e.label,
meta: e.meta,
})),
})
}
/**
* GET /dashboard/top-late
*
* Top 5 clients avec le plus de factures en retard (status actif +
* due_date dépassée).
*/
async topLate({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const data = await topLatePayers(organizationId)
return response.json({ data })
}
}

View File

@ -11,6 +11,7 @@ import {
} from '#validators/import_batch'
import { resolveClient } from '#services/resolve_client'
import { createImportBatchFromFilenames } from '#services/import_batch'
import { recordActivity } from '#services/activity_recorder'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
@ -151,6 +152,14 @@ export default class ImportBatchesController {
draft.invoiceId = created.id
await draft.save()
await recordActivity({
organizationId,
kind: 'invoice_imported',
label: `Facture <b>${created.numero}</b> importée et validée`,
meta: { invoiceId: created.id, clientId: client.id },
trx,
})
return created
})

View File

@ -10,6 +10,7 @@ import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import { resolveClient } from '#services/resolve_client'
import { recordActivity } from '#services/activity_recorder'
const PAGE_SIZE = 50
@ -333,6 +334,15 @@ export default class InvoicesController {
.from('organizations')
.where('id', organizationId)
.increment('rubis_count', 1)
// Journal d'activité (cf. dashboard activity feed).
await recordActivity({
organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> marquée encaissée`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
})
return response.json({ data: serializeInvoice(invoice) })

View File

@ -0,0 +1,9 @@
import { ActivityEventSchema } 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 ActivityEvent extends ActivityEventSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
}

View File

@ -0,0 +1,40 @@
import { DateTime } from 'luxon'
import ActivityEvent from '#models/activity_event'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
type RecordOpts = {
organizationId: string
kind: EventKind
label: string
meta?: Record<string, unknown>
at?: DateTime
trx?: TransactionClientContract
}
/**
* Enregistre un événement dans le journal d'activité (append-only).
* Appelé depuis :
* - SendRelanceJob (relance_sent)
* - InvoicesController.markPaid (invoice_paid)
* - ImportBatchesController.validateDraft (invoice_imported)
* - SendRelanceJob quand step.requires_manual_validation (warning_drafted)
*
* Les labels acceptent un HTML léger (<b>) pour permettre au SPA de
* mettre en gras les noms d'entité toujours composé côté serveur,
* jamais d'input utilisateur brut.
*/
export async function recordActivity(opts: RecordOpts): Promise<ActivityEvent> {
const { organizationId, kind, label, meta = {}, at, trx } = opts
return ActivityEvent.create(
{
organizationId,
kind,
label,
meta,
at: at ?? DateTime.now(),
},
trx ? { client: trx } : undefined
)
}

View File

@ -0,0 +1,150 @@
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import { DateTime } from 'luxon'
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
export type DashboardKpis = {
rubisCount: number
rubisThisMonth: number
// 1 rubis = 10 minutes libérées (cf. CLAUDE.md → glossaire)
hoursLiberatedThisMonth: number
encaisseCents: number
encaisseDeltaCents: number
dsoDays: number
dsoDeltaDays: number
factureToRelance: number
factureInRelance: number
factureNewToday: number
miseEnDemeurePending: number
monthlyGoalProgress: number
// Rang relatif à la cohorte (placeholder V1, calculé en V2 avec assez de data)
percentile?: number
}
function startOfMonth(d: DateTime): Date {
return d.startOf('month').toJSDate()
}
function startOfDay(d: DateTime): Date {
return d.startOf('day').toJSDate()
}
/**
* Calcule les KPIs dashboard pour une organisation.
*
* V1 implémentation simple sans cache. Quelques metrics avancés
* (DSO, percentile) sont à 0 ou null tant qu'on a pas assez d'historique.
* Le contrat reste stable côté SPA.
*/
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
const now = DateTime.now()
const monthStart = startOfMonth(now)
const todayStart = startOfDay(now)
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
const org = await Organization.findOrFail(organizationId)
// Counts par statut + factures récentes
const counts = (await db
.from('invoices')
.where('organization_id', organizationId)
.select(
db.raw(`count(*) filter (where status = 'pending')::int as to_relance`),
db.raw(`count(*) filter (where status = 'in_relance')::int as in_relance`),
db.raw(`count(*) filter (where created_at >= ?)::int as new_today`, [todayStart])
)
.first()) as { to_relance: number; in_relance: number; new_today: number } | undefined
// Sommes d'encaissement (paid_at) ce mois et le précédent
const paidStats = (await db
.from('invoices')
.where('organization_id', organizationId)
.where('status', 'paid')
.select(
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ?), 0)::int as this_month`,
[monthStart]
),
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ? and paid_at < ?), 0)::int as prev_month`,
[prevMonthStart, monthStart]
),
db.raw(
`coalesce(sum(rubis_earned) filter (where paid_at >= ?), 0)::int as rubis_this_month`,
[monthStart]
),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ?), 0)::int as dso_this_month`,
[monthStart]
),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ? and paid_at < ?), 0)::int as dso_prev_month`,
[prevMonthStart, monthStart]
)
)
.first()) as
| {
this_month: number
prev_month: number
rubis_this_month: number
dso_this_month: number
dso_prev_month: number
}
| undefined
const encaisseCents = paidStats?.this_month ?? 0
const encaisseDeltaCents = encaisseCents - (paidStats?.prev_month ?? 0)
const rubisThisMonth = paidStats?.rubis_this_month ?? 0
const dsoDays = paidStats?.dso_this_month ?? 0
const dsoDeltaDays = dsoDays - (paidStats?.dso_prev_month ?? 0)
return {
rubisCount: org.rubisCount,
rubisThisMonth,
hoursLiberatedThisMonth: rubisThisMonth * 10,
encaisseCents,
encaisseDeltaCents,
dsoDays,
dsoDeltaDays,
factureToRelance: counts?.to_relance ?? 0,
factureInRelance: counts?.in_relance ?? 0,
factureNewToday: counts?.new_today ?? 0,
// Mise en demeure pending — sera calculé quand RelanceTask est branché
// (count des steps requires_manual_validation programmées). Pour V1 : 0.
miseEnDemeurePending: 0,
// Goal progress (V1 placeholder) : ratio rubis_count / 250 (objectif
// mensuel arbitraire). À paramétrer plus tard.
monthlyGoalProgress: Math.min(100, Math.round((rubisThisMonth / 25) * 100)),
percentile: undefined,
}
}
/**
* Top des clients en retard (top 5 par défaut).
* Compte les factures actives dont due_date est dépassée, agrégées par client.
*/
export async function topLatePayers(
organizationId: string,
limit = 5
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
const today = startOfDay(DateTime.now())
const rows = await db
.from('invoices')
.innerJoin('clients', 'clients.id', 'invoices.client_id')
.where('invoices.organization_id', organizationId)
.whereRaw(`invoices.status::text in ${ACTIVE_INVOICE_STATUSES}`)
.where('invoices.due_date', '<', today)
.groupBy('clients.id', 'clients.name')
.select('clients.id as client_id', 'clients.name as name')
.select(db.raw('count(*)::int as late_invoices_count'))
.orderBy('late_invoices_count', 'desc')
.limit(limit)
return rows.map((r) => ({
clientId: r.client_id,
name: r.name,
lateInvoicesCount: r.late_invoices_count,
}))
}

View File

@ -0,0 +1,44 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'activity_events'
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')
// Append-only — pas d'update sur cette table. Les events sont des
// snapshots historiques, pas un état mutable.
table
.enum(
'kind',
['relance_sent', 'invoice_paid', 'invoice_imported', 'warning_drafted'],
{ useNative: true, enumName: 'activity_event_kind' }
)
.notNullable()
table.timestamp('at').notNullable()
// Label HTML léger autorisé (<b> pour le nom client/numero) — sanitisé
// côté serveur quand on construit le label, pas de user input direct.
table.text('label').notNullable()
// Méta jsonb : { invoiceId?, clientId?, ... } pour permettre au SPA
// de cliquer sur l'event vers la facture/le client concerné.
table.jsonb('meta').notNullable().defaultTo('{}')
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
table.index(['organization_id', 'at'])
})
}
async down() {
this.schema.dropTable(this.tableName)
this.schema.raw('DROP TYPE IF EXISTS activity_event_kind')
}
}

View File

@ -7,6 +7,27 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export class ActivityEventSchema extends BaseModel {
static $columns = ['at', 'createdAt', 'id', 'kind', 'label', 'meta', 'organizationId', 'updatedAt'] as const
$columns = ActivityEventSchema.$columns
@column.dateTime()
declare at: DateTime
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
@column()
declare label: string
@column()
declare meta: { invoiceId?: string; clientId?: string; planStepOrder?: number; [k: string]: unknown }
@column()
declare organizationId: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class AuthAccessTokenSchema extends BaseModel {
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
$columns = AuthAccessTokenSchema.$columns

View File

@ -21,6 +21,18 @@ export default {
},
},
},
activity_events: {
columns: {
kind: {
tsType:
"'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'",
},
meta: {
tsType:
"{ invoiceId?: string; clientId?: string; planStepOrder?: number; [k: string]: unknown }",
},
},
},
import_drafts: {
columns: {
status: {

View File

@ -85,6 +85,19 @@ router
.as('plans')
.use(middleware.auth())
/**
* Dashboard auth requise. Calculs agrégés on-the-fly (pas de cache V1).
*/
router
.group(() => {
router.get('kpis', [controllers.Dashboard, 'kpis']).as('kpis')
router.get('activity', [controllers.Dashboard, 'activity']).as('activity')
router.get('top-late', [controllers.Dashboard, 'topLate']).as('top-late')
})
.prefix('dashboard')
.as('dashboard')
.use(middleware.auth())
/**
* Invoices auth requise. Ordre IMPORTANT : les routes statiques
* (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id

View File

@ -0,0 +1,48 @@
meta {
name: 01 KPIs
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/dashboard/kpis
body: none
auth: inherit
}
tests {
test("200 OK", function () {
expect(res.getStatus()).to.equal(200);
});
test("KPIs shape", function () {
const k = res.getBody().data;
for (const key of [
"rubisCount", "rubisThisMonth", "hoursLiberatedThisMonth",
"encaisseCents", "encaisseDeltaCents",
"dsoDays", "dsoDeltaDays",
"factureToRelance", "factureInRelance", "factureNewToday",
"miseEnDemeurePending", "monthlyGoalProgress"
]) {
expect(k).to.have.property(key);
}
});
}
docs {
GET /api/v1/dashboard/kpis
Calculs agrégés sur les invoices de l'org :
- `rubisCount` lu sur Organization (compteur cumulé)
- `rubisThisMonth` = sum(rubis_earned where paid_at >= startOfMonth)
- `hoursLiberatedThisMonth` = rubisThisMonth × 10 (1 rubis = 10 min)
- `encaisseCents` = sum(amount_ttc_cents where paid this month)
- `encaisseDeltaCents` = ce mois mois précédent
- `dsoDays` = avg(paid_at issue_date) en jours, sur factures payées ce mois
- `dsoDeltaDays` = idem delta vs mois précédent
- `factureToRelance` = count(status='pending')
- `factureInRelance` = count(status='in_relance')
- `factureNewToday` = count(created_at >= today)
- `miseEnDemeurePending` = 0 (à brancher quand RelanceTask sera là)
- `monthlyGoalProgress` = clamp(rubisThisMonth/25*100, 0, 100) — placeholder
- `percentile` = undefined V1
}

View File

@ -0,0 +1,48 @@
meta {
name: 02 Activity
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/v1/dashboard/activity
body: none
auth: inherit
}
tests {
test("200 OK", function () {
expect(res.getStatus()).to.equal(200);
});
test("data is array", function () {
expect(res.getBody().data).to.be.an("array");
});
}
docs {
GET /api/v1/dashboard/activity
Journal d'activité — append-only, 20 derniers events de l'org, plus
récent en tête.
Kinds émis :
- `invoice_paid` (Invoices → Mark paid)
- `invoice_imported` (Imports → Validate draft)
- `relance_sent` (à venir, SendRelanceJob)
- `warning_drafted` (à venir, mise en demeure)
Chaque event a :
```json
{
"id": "<uuid>",
"kind": "...",
"at": "ISO 8601",
"label": "Texte HTML léger (<b> autorisé pour les noms d'entité)",
"meta": { "invoiceId": "...", "clientId": "..." }
}
```
Pour générer des events : encaisse une facture (Invoices → 06 Mark paid)
ou valide un draft d'import (Imports → 03 Validate draft) puis re-call
cette route.
}

View File

@ -0,0 +1,37 @@
meta {
name: 03 Top late payers
type: http
seq: 3
}
get {
url: {{baseUrl}}/api/v1/dashboard/top-late
body: none
auth: inherit
}
tests {
test("200 OK", function () {
expect(res.getStatus()).to.equal(200);
});
test("data shape", function () {
const list = res.getBody().data;
expect(list).to.be.an("array");
if (list.length > 0) {
expect(list[0]).to.have.property("clientId");
expect(list[0]).to.have.property("name");
expect(list[0]).to.have.property("lateInvoicesCount");
}
});
}
docs {
GET /api/v1/dashboard/top-late
Top 5 des clients ayant le plus de factures en retard. "En retard" =
status actif (pending / in_relance / awaiting_user_confirmation) ET
due_date < today.
Pour générer des données : crée des factures avec une `dueDate` dans
le passé via Invoices → 04 Create.
}

View File

@ -0,0 +1,21 @@
meta {
name: Dashboard
seq: 8
}
auth {
mode: bearer
}
auth:bearer {
token: {{token}}
}
docs {
## Dashboard — KPIs, journal d'activité, top clients en retard
Tout est calculé on-the-fly côté SQL (pas de cache V1). Le journal
d'activité est append-only et alimenté automatiquement par les
controllers métier (mark-paid → invoice_paid, validate-draft →
invoice_imported, etc.).
}