feat(usage): cross-product usage queries + product breakdown

- Make productId optional in repository list() — omitting it queries all products
- Add resolveProductFilter() helper: productId=_all skips filter, specific value overrides default
- Add ProductBreakdown interface for per-product aggregation
- Summary endpoint now returns productBreakdown[] alongside model/source breakdowns
- Enables admin to compare John's LysnrAI usage vs MindLyst usage
This commit is contained in:
saravanakumardb1 2026-02-15 19:47:23 -08:00
parent 1cf74d22fa
commit 972a1a21d7
3 changed files with 44 additions and 8 deletions

View File

@ -22,14 +22,16 @@ export async function getByDate(userId: string, date: string): Promise<UsageDoc
export async function list(
options: { userId?: string; days?: number; limit?: number; productId?: string } = {}
): Promise<UsageDoc[]> {
const { userId, days = 30, limit = 100, productId = '' } = options;
const { userId, days = 30, limit = 100, productId } = options;
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
let queryText = 'SELECT * FROM c WHERE c.productId = @productId AND c.date >= @since';
const parameters: { name: string; value: string | number }[] = [
{ name: '@productId', value: productId },
{ name: '@since', value: since },
];
let queryText = 'SELECT * FROM c WHERE c.date >= @since';
const parameters: { name: string; value: string | number }[] = [{ name: '@since', value: since }];
if (productId) {
queryText += ' AND c.productId = @productId';
parameters.push({ name: '@productId', value: productId });
}
if (userId) {
queryText += ' AND c.userId = @userId';

View File

@ -17,8 +17,22 @@ import {
type UsageDoc,
type ModelBreakdown,
type SourceBreakdown,
type ProductBreakdown,
} from './types.js';
/**
* Resolve productId for usage queries.
* - If query has productId=_all return undefined (cross-product)
* - If query has a specific productId use it
* - Otherwise use the request's default productId from JWT/header/env
*/
function resolveProductFilter(req: import('fastify').FastifyRequest): string | undefined {
const qp = (req.query as Record<string, string>).productId;
if (qp === '_all') return undefined;
if (qp && qp.length > 0) return qp;
return getRequestProductId(req);
}
/**
* Plan limits configurable per product via PLAN_LIMITS_JSON env var.
* Format: JSON object keyed by plan name, value is metriclimit map.
@ -47,7 +61,7 @@ export async function usageRoutes(app: FastifyInstance) {
// List usage
app.get('/usage', async req => {
const { userId, days = '30', limit = '100' } = req.query as Record<string, string>;
const productId = getRequestProductId(req);
const productId = resolveProductFilter(req);
const records = await repo.list({
userId,
days: Number(days),
@ -60,11 +74,12 @@ export async function usageRoutes(app: FastifyInstance) {
// Summary
app.get('/usage/summary', async req => {
const { userId, days = '30' } = req.query as Record<string, string>;
const productId = getRequestProductId(req);
const productId = resolveProductFilter(req);
const records = await repo.list({ userId, days: Number(days), productId });
const byModel: Record<string, { tokens: number; requests: number; cost: number }> = {};
const bySource: Record<string, { tokens: number; requests: number; cost: number }> = {};
const byProduct: Record<string, { tokens: number; requests: number; cost: number }> = {};
for (const r of records) {
const m = r.model || 'gpt-4o-mini';
if (!byModel[m]) byModel[m] = { tokens: 0, requests: 0, cost: 0 };
@ -77,6 +92,12 @@ export async function usageRoutes(app: FastifyInstance) {
bySource[s].tokens += r.tokensUsed;
bySource[s].requests += r.dictations;
bySource[s].cost += r.costUsd;
const p = r.productId;
if (!byProduct[p]) byProduct[p] = { tokens: 0, requests: 0, cost: 0 };
byProduct[p].tokens += r.tokensUsed;
byProduct[p].requests += r.dictations;
byProduct[p].cost += r.costUsd;
}
const modelBreakdown: ModelBreakdown[] = Object.entries(byModel).map(([model, s]) => ({
@ -89,6 +110,11 @@ export async function usageRoutes(app: FastifyInstance) {
...s,
}));
const productBreakdown: ProductBreakdown[] = Object.entries(byProduct).map(([pid, s]) => ({
productId: pid,
...s,
}));
return {
totalWords: records.reduce((sum, r) => sum + r.words, 0),
totalDictations: records.reduce((sum, r) => sum + r.dictations, 0),
@ -97,6 +123,7 @@ export async function usageRoutes(app: FastifyInstance) {
records,
modelBreakdown,
sourceBreakdown,
productBreakdown,
};
});

View File

@ -43,6 +43,13 @@ export interface ModelBreakdown {
cost: number;
}
export interface ProductBreakdown {
productId: string;
tokens: number;
requests: number;
cost: number;
}
export interface MonthlyUsage {
tokens: number;
words: number;