Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/app/server/api/usage/current.get.ts
Original file line number Diff line number Diff line change
@@ -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<UsageSummary> => {
const userId = requireUserId(event)
return readUsageSummary(prisma, userId, FREE_TIER_QUOTAS, new Date())
})
196 changes: 196 additions & 0 deletions apps/app/server/utils/metering.ts
Original file line number Diff line number Diff line change
@@ -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<UsageEventType, string>

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<UsageEventType, string>

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<UsageCounterRow | null>
upsert(args: {
where: CounterWhere
create: ReturnType<typeof counterCreateData>
update: ReturnType<typeof counterUpdateData>
}): Promise<UsageCounterRow>
}

interface MeteringTx {
usageEvent: { create(args: { data: ReturnType<typeof eventCreateData> }): Promise<unknown> }
usageCounter: Pick<UsageCounterDelegate, 'upsert'>
}

/** Client minimal requis par le service (satisfait par le client Prisma). */
export interface MeteringClient {
usageCounter: Pick<UsageCounterDelegate, 'findUnique'>
$transaction<T>(fn: (tx: MeteringTx) => Promise<T>): Promise<T>
}

/**
* 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<void> {
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<UsageCounterSnapshot> {
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<UsageSummary> {
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<boolean> {
const snapshot = await readSnapshot(client, userId, now)
return !isQuotaExceeded(type, snapshot[type], quotas)
}
27 changes: 27 additions & 0 deletions apps/app/server/utils/session.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading