From 0fd8dfa58aeffedf298a86a7d1a2db384871a4a5 Mon Sep 17 00:00:00 2001 From: "Thibault Beaumont (backend agent)" Date: Tue, 9 Jun 2026 12:34:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(metering):=20service=20d'enregistrement=20?= =?UTF-8?q?+=20endpoint=20usage=20courant=20(THI-126=20=E2=80=94=202/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branche le metering sur la base posée en 1/2. - server/utils/metering.ts: recordUsageEvent (event + upsert compteur atomique), readUsageSummary, isUsageAllowed (garde de quota). Client injectable minimal (MeteringClient) à la manière de DbPinger -> testable sans base. - server/utils/session.ts: requireUserId(event) découplé du lot Auth (lit event.context.userId renseigné par le middleware auth THI-131) -> 401 sinon. - GET /api/usage/current: usage de la période en cours + état des quotas. - Tests: 8 specs (builders purs, création event, agrégation compteur, gate quota). Co-Authored-By: Paperclip --- apps/app/server/api/usage/current.get.ts | 12 ++ apps/app/server/utils/metering.ts | 196 +++++++++++++++++++++++ apps/app/server/utils/session.ts | 27 ++++ apps/app/test/metering.spec.ts | 167 +++++++++++++++++++ 4 files changed, 402 insertions(+) create mode 100644 apps/app/server/api/usage/current.get.ts create mode 100644 apps/app/server/utils/metering.ts create mode 100644 apps/app/server/utils/session.ts create mode 100644 apps/app/test/metering.spec.ts diff --git a/apps/app/server/api/usage/current.get.ts b/apps/app/server/api/usage/current.get.ts new file mode 100644 index 0000000..bbb3cab --- /dev/null +++ b/apps/app/server/api/usage/current.get.ts @@ -0,0 +1,12 @@ +import type { UsageSummary } from '@cvo/shared' +import { FREE_TIER_QUOTAS } from '@cvo/shared' +import { prisma } from '../../utils/prisma' +import { readUsageSummary } from '../../utils/metering' +import { requireUserId } from '../../utils/session' + +// GET /api/usage/current — usage de la période en cours + état des quotas, pour +// l'utilisateur authentifié. Aucune donnée de contenu : uniquement des compteurs. +export default defineEventHandler(async (event): Promise => { + const userId = requireUserId(event) + return readUsageSummary(prisma, userId, FREE_TIER_QUOTAS, new Date()) +}) diff --git a/apps/app/server/utils/metering.ts b/apps/app/server/utils/metering.ts new file mode 100644 index 0000000..f551769 --- /dev/null +++ b/apps/app/server/utils/metering.ts @@ -0,0 +1,196 @@ +/** + * Service de metering (THI-126) — enregistre les événements d'usage et agrège le + * compteur de période. Appelé par les chemins génération (THI-124) et export PDF + * (THI-125) : chaque action y crée un `usage_event` et incrémente le `usage_counter`. + * + * Conception testable (cf. `DbPinger` de health.ts) : la logique de mapping/agrégat + * est PURE et testée unitairement ; l'accès base passe par une interface minimale + * `MeteringClient`, ce qui permet d'injecter un faux client dans les tests sans base. + * + * RGPD : on n'écrit que des compteurs et des volumes de tokens — jamais de contenu. + */ +import { + EMPTY_USAGE_SNAPSHOT, + buildUsageSummary, + isQuotaExceeded, + usagePeriod, + type UsageCounterSnapshot, + type UsageEventType, + type UsageQuotas, + type UsageSummary, +} from '@cvo/shared' + +/** Enum Prisma (UPPER_SNAKE) ↔ type partagé (lower). */ +const EVENT_TYPE_TO_PRISMA = { + generation: 'GENERATION', + export_pdf: 'EXPORT_PDF', + extraction: 'EXTRACTION', +} as const satisfies Record + +type PrismaUsageEventType = (typeof EVENT_TYPE_TO_PRISMA)[UsageEventType] + +/** Colonne de compteur incrémentée pour un type d'événement donné. */ +const COUNTER_COLUMN = { + generation: 'generationCount', + export_pdf: 'exportPdfCount', + extraction: 'extractionCount', +} as const satisfies Record + +type CounterColumn = (typeof COUNTER_COLUMN)[UsageEventType] + +/** Ligne `usage_counters` telle que lue/écrite (sous-ensemble utile). */ +export interface UsageCounterRow { + generationCount: number + exportPdfCount: number + extractionCount: number + tokensIn: number + tokensOut: number + billableCount: number +} + +/** Entrée d'un enregistrement d'usage. */ +export interface RecordUsageInput { + userId: string + type: UsageEventType + /** Tokens LLM (jamais le contenu). */ + tokensIn?: number + tokensOut?: number + /** Compte dans la base billing freemium. Défaut : false (gratuit au MVP). */ + billable?: boolean +} + +// ── Builders purs (testés) ─────────────────────────────────────────────────── + +/** Données de création de l'événement (forme Prisma). Pur. */ +export function eventCreateData(input: RecordUsageInput, period: string) { + return { + userId: input.userId, + type: EVENT_TYPE_TO_PRISMA[input.type] as PrismaUsageEventType, + period, + tokensIn: input.tokensIn ?? 0, + tokensOut: input.tokensOut ?? 0, + billable: input.billable ?? false, + } +} + +/** Données de création d'un compteur (première action de la période). Pur. */ +export function counterCreateData(input: RecordUsageInput, period: string) { + return { + userId: input.userId, + period, + generationCount: input.type === 'generation' ? 1 : 0, + exportPdfCount: input.type === 'export_pdf' ? 1 : 0, + extractionCount: input.type === 'extraction' ? 1 : 0, + tokensIn: input.tokensIn ?? 0, + tokensOut: input.tokensOut ?? 0, + billableCount: input.billable ? 1 : 0, + } +} + +/** Données d'incrément d'un compteur existant (opérateurs Prisma `increment`). Pur. */ +export function counterUpdateData(input: RecordUsageInput) { + const column: CounterColumn = COUNTER_COLUMN[input.type] + return { + [column]: { increment: 1 }, + tokensIn: { increment: input.tokensIn ?? 0 }, + tokensOut: { increment: input.tokensOut ?? 0 }, + billableCount: { increment: input.billable ? 1 : 0 }, + } +} + +/** Snapshot agrégé à partir d'une ligne compteur (ou compteur vide si absente). Pur. */ +export function snapshotFromRow(row: UsageCounterRow | null): UsageCounterSnapshot { + if (!row) return { ...EMPTY_USAGE_SNAPSHOT } + return { + generation: row.generationCount, + export_pdf: row.exportPdfCount, + extraction: row.extractionCount, + tokensIn: row.tokensIn, + tokensOut: row.tokensOut, + billableCount: row.billableCount, + } +} + +// ── Accès base (interface minimale → injectable / testable) ─────────────────── + +type CounterWhere = { userId_period: { userId: string; period: string } } + +interface UsageCounterDelegate { + findUnique(args: { where: CounterWhere }): Promise + upsert(args: { + where: CounterWhere + create: ReturnType + update: ReturnType + }): Promise +} + +interface MeteringTx { + usageEvent: { create(args: { data: ReturnType }): Promise } + usageCounter: Pick +} + +/** Client minimal requis par le service (satisfait par le client Prisma). */ +export interface MeteringClient { + usageCounter: Pick + $transaction(fn: (tx: MeteringTx) => Promise): Promise +} + +/** + * Enregistre un événement d'usage et agrège le compteur de période, de façon + * atomique (création de l'événement + upsert du compteur dans une transaction). + */ +export async function recordUsageEvent( + client: MeteringClient, + input: RecordUsageInput, + now: Date, +): Promise { + const period = usagePeriod(now) + const where: CounterWhere = { userId_period: { userId: input.userId, period } } + await client.$transaction(async (tx) => { + await tx.usageEvent.create({ data: eventCreateData(input, period) }) + await tx.usageCounter.upsert({ + where, + create: counterCreateData(input, period), + update: counterUpdateData(input), + }) + }) +} + +/** Lit le snapshot d'usage d'un utilisateur pour la période en cours. */ +export async function readSnapshot( + client: MeteringClient, + userId: string, + now: Date, +): Promise { + const period = usagePeriod(now) + const row = await client.usageCounter.findUnique({ + where: { userId_period: { userId, period } }, + }) + return snapshotFromRow(row) +} + +/** Usage courant (snapshot + état des quotas) — réponse de l'endpoint. */ +export async function readUsageSummary( + client: MeteringClient, + userId: string, + quotas: UsageQuotas, + now: Date, +): Promise { + const snapshot = await readSnapshot(client, userId, now) + return buildUsageSummary(usagePeriod(now), snapshot, quotas) +} + +/** + * Garde de quota : `true` si une action de ce type est encore permise pour la + * période en cours. Les chemins génération/export DOIVENT l'appeler avant d'agir. + */ +export async function isUsageAllowed( + client: MeteringClient, + userId: string, + type: UsageEventType, + quotas: UsageQuotas, + now: Date, +): Promise { + const snapshot = await readSnapshot(client, userId, now) + return !isQuotaExceeded(type, snapshot[type], quotas) +} diff --git a/apps/app/server/utils/session.ts b/apps/app/server/utils/session.ts new file mode 100644 index 0000000..fa9f921 --- /dev/null +++ b/apps/app/server/utils/session.ts @@ -0,0 +1,27 @@ +/** + * Résolution de l'utilisateur courant côté serveur (Nitro). + * + * Découplage volontaire du lot Auth (THI-131) : on lit `event.context.userId`, + * renseigné par le middleware d'authentification (Better Auth). Ce WS (THI-126) + * n'importe donc pas l'instance auth, ce qui le garde indépendant de l'ordre de + * merge. Quand le middleware d'auth est en place, l'id est disponible ici ; + * sinon l'accès est refusé (401) plutôt que d'attribuer l'usage au mauvais compte. + */ +import { createError, type H3Event } from 'h3' + +// Le middleware d'auth renseigne l'id utilisateur dans le contexte de requête. +declare module 'h3' { + interface H3EventContext { + /** Id de l'utilisateur authentifié (renseigné par le middleware auth, THI-131). */ + userId?: string + } +} + +/** Renvoie l'id de l'utilisateur authentifié, ou lève une 401 si absent. */ +export function requireUserId(event: H3Event): string { + const userId = event.context.userId + if (!userId) { + throw createError({ statusCode: 401, statusMessage: 'Authentification requise' }) + } + return userId +} diff --git a/apps/app/test/metering.spec.ts b/apps/app/test/metering.spec.ts new file mode 100644 index 0000000..f5ad88d --- /dev/null +++ b/apps/app/test/metering.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest' +import { + counterCreateData, + counterUpdateData, + eventCreateData, + isUsageAllowed, + readUsageSummary, + recordUsageEvent, + snapshotFromRow, + type MeteringClient, + type RecordUsageInput, + type UsageCounterRow, +} from '../server/utils/metering' + +const NOW = new Date('2026-06-09T10:00:00.000Z') // période 202606 + +/** + * Faux client de metering : simule un compteur en mémoire et applique les + * opérateurs `increment` de l'upsert. Permet de prouver event + agrégat sans base. + */ +function makeFakeClient(initial: UsageCounterRow | null = null) { + let counter = initial + const events: ReturnType[] = [] + + const client: MeteringClient = { + usageCounter: { + findUnique: async () => counter, + }, + $transaction: async (fn) => + fn({ + usageEvent: { + create: async ({ data }) => { + events.push(data) + }, + }, + usageCounter: { + upsert: async ({ create, update }) => { + if (!counter) { + counter = { ...create } as UsageCounterRow + } else { + // Applique chaque { increment: n } sur la colonne correspondante. + for (const [key, op] of Object.entries(update)) { + const inc = (op as { increment: number }).increment + counter[key as keyof UsageCounterRow] += inc + } + } + return counter + }, + }, + }), + } + + return { client, events, snapshot: () => counter } +} + +describe('builders purs', () => { + it('eventCreateData mappe le type vers l’enum Prisma et défaut les tokens', () => { + const input: RecordUsageInput = { userId: 'u1', type: 'export_pdf' } + expect(eventCreateData(input, '202606')).toEqual({ + userId: 'u1', + type: 'EXPORT_PDF', + period: '202606', + tokensIn: 0, + tokensOut: 0, + billable: false, + }) + }) + + it('counterCreateData met à 1 la bonne colonne et 0 les autres', () => { + const data = counterCreateData({ userId: 'u1', type: 'generation', billable: true }, '202606') + expect(data.generationCount).toBe(1) + expect(data.exportPdfCount).toBe(0) + expect(data.extractionCount).toBe(0) + expect(data.billableCount).toBe(1) + }) + + it('counterUpdateData incrémente la bonne colonne', () => { + const data = counterUpdateData({ userId: 'u1', type: 'extraction', tokensIn: 12, tokensOut: 3 }) + expect(data).toMatchObject({ + extractionCount: { increment: 1 }, + tokensIn: { increment: 12 }, + tokensOut: { increment: 3 }, + billableCount: { increment: 0 }, + }) + }) + + it('snapshotFromRow renvoie un compteur vide si la ligne est absente', () => { + expect(snapshotFromRow(null)).toEqual({ + generation: 0, + export_pdf: 0, + extraction: 0, + tokensIn: 0, + tokensOut: 0, + billableCount: 0, + }) + }) +}) + +describe('recordUsageEvent', () => { + it('crée un usage_event et initialise le compteur à la première action', async () => { + const fake = makeFakeClient() + await recordUsageEvent( + fake.client, + { userId: 'u1', type: 'generation', tokensIn: 100, tokensOut: 200, billable: true }, + NOW, + ) + + expect(fake.events).toHaveLength(1) + expect(fake.events[0]).toMatchObject({ userId: 'u1', type: 'GENERATION', period: '202606' }) + expect(fake.snapshot()).toMatchObject({ + generationCount: 1, + tokensIn: 100, + tokensOut: 200, + billableCount: 1, + }) + }) + + it('agrège : deux générations incrémentent le compteur de période', async () => { + const fake = makeFakeClient() + await recordUsageEvent(fake.client, { userId: 'u1', type: 'generation', tokensIn: 10 }, NOW) + await recordUsageEvent(fake.client, { userId: 'u1', type: 'generation', tokensIn: 5 }, NOW) + await recordUsageEvent(fake.client, { userId: 'u1', type: 'export_pdf' }, NOW) + + expect(fake.events).toHaveLength(3) + expect(fake.snapshot()).toMatchObject({ generationCount: 2, exportPdfCount: 1, tokensIn: 15 }) + }) +}) + +describe('readUsageSummary & isUsageAllowed', () => { + it('expose l’usage courant agrégé + l’état des quotas', async () => { + const row: UsageCounterRow = { + generationCount: 5, + exportPdfCount: 1, + extractionCount: 0, + tokensIn: 1000, + tokensOut: 2000, + billableCount: 0, + } + const fake = makeFakeClient(row) + const summary = await readUsageSummary(fake.client, 'u1', { generation: 5 }, NOW) + expect(summary.period).toBe('202606') + expect(summary.counter.generation).toBe(5) + const gen = summary.quotas.find((q) => q.type === 'generation')! + expect(gen.exceeded).toBe(true) + }) + + it('refuse une génération quand le quota de période est atteint', async () => { + const row = { ...snapshotToRow(), generationCount: 5 } + const fake = makeFakeClient(row) + expect(await isUsageAllowed(fake.client, 'u1', 'generation', { generation: 5 }, NOW)).toBe( + false, + ) + expect(await isUsageAllowed(fake.client, 'u1', 'export_pdf', { generation: 5 }, NOW)).toBe(true) + }) +}) + +/** Ligne compteur à zéro, utilitaire de test. */ +function snapshotToRow(): UsageCounterRow { + return { + generationCount: 0, + exportPdfCount: 0, + extractionCount: 0, + tokensIn: 0, + tokensOut: 0, + billableCount: 0, + } +}