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:
parent
1cf74d22fa
commit
972a1a21d7
@ -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';
|
||||
|
||||
@ -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 metric→limit 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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user