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(
|
export async function list(
|
||||||
options: { userId?: string; days?: number; limit?: number; productId?: string } = {}
|
options: { userId?: string; days?: number; limit?: number; productId?: string } = {}
|
||||||
): Promise<UsageDoc[]> {
|
): 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);
|
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';
|
let queryText = 'SELECT * FROM c WHERE c.date >= @since';
|
||||||
const parameters: { name: string; value: string | number }[] = [
|
const parameters: { name: string; value: string | number }[] = [{ name: '@since', value: since }];
|
||||||
{ name: '@productId', value: productId },
|
|
||||||
{ name: '@since', value: since },
|
if (productId) {
|
||||||
];
|
queryText += ' AND c.productId = @productId';
|
||||||
|
parameters.push({ name: '@productId', value: productId });
|
||||||
|
}
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
queryText += ' AND c.userId = @userId';
|
queryText += ' AND c.userId = @userId';
|
||||||
|
|||||||
@ -17,8 +17,22 @@ import {
|
|||||||
type UsageDoc,
|
type UsageDoc,
|
||||||
type ModelBreakdown,
|
type ModelBreakdown,
|
||||||
type SourceBreakdown,
|
type SourceBreakdown,
|
||||||
|
type ProductBreakdown,
|
||||||
} from './types.js';
|
} 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.
|
* Plan limits — configurable per product via PLAN_LIMITS_JSON env var.
|
||||||
* Format: JSON object keyed by plan name, value is metric→limit map.
|
* Format: JSON object keyed by plan name, value is metric→limit map.
|
||||||
@ -47,7 +61,7 @@ export async function usageRoutes(app: FastifyInstance) {
|
|||||||
// List usage
|
// List usage
|
||||||
app.get('/usage', async req => {
|
app.get('/usage', async req => {
|
||||||
const { userId, days = '30', limit = '100' } = req.query as Record<string, string>;
|
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({
|
const records = await repo.list({
|
||||||
userId,
|
userId,
|
||||||
days: Number(days),
|
days: Number(days),
|
||||||
@ -60,11 +74,12 @@ export async function usageRoutes(app: FastifyInstance) {
|
|||||||
// Summary
|
// Summary
|
||||||
app.get('/usage/summary', async req => {
|
app.get('/usage/summary', async req => {
|
||||||
const { userId, days = '30' } = req.query as Record<string, string>;
|
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 records = await repo.list({ userId, days: Number(days), productId });
|
||||||
|
|
||||||
const byModel: Record<string, { tokens: number; requests: number; cost: number }> = {};
|
const byModel: Record<string, { tokens: number; requests: number; cost: number }> = {};
|
||||||
const bySource: 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) {
|
for (const r of records) {
|
||||||
const m = r.model || 'gpt-4o-mini';
|
const m = r.model || 'gpt-4o-mini';
|
||||||
if (!byModel[m]) byModel[m] = { tokens: 0, requests: 0, cost: 0 };
|
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].tokens += r.tokensUsed;
|
||||||
bySource[s].requests += r.dictations;
|
bySource[s].requests += r.dictations;
|
||||||
bySource[s].cost += r.costUsd;
|
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]) => ({
|
const modelBreakdown: ModelBreakdown[] = Object.entries(byModel).map(([model, s]) => ({
|
||||||
@ -89,6 +110,11 @@ export async function usageRoutes(app: FastifyInstance) {
|
|||||||
...s,
|
...s,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const productBreakdown: ProductBreakdown[] = Object.entries(byProduct).map(([pid, s]) => ({
|
||||||
|
productId: pid,
|
||||||
|
...s,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalWords: records.reduce((sum, r) => sum + r.words, 0),
|
totalWords: records.reduce((sum, r) => sum + r.words, 0),
|
||||||
totalDictations: records.reduce((sum, r) => sum + r.dictations, 0),
|
totalDictations: records.reduce((sum, r) => sum + r.dictations, 0),
|
||||||
@ -97,6 +123,7 @@ export async function usageRoutes(app: FastifyInstance) {
|
|||||||
records,
|
records,
|
||||||
modelBreakdown,
|
modelBreakdown,
|
||||||
sourceBreakdown,
|
sourceBreakdown,
|
||||||
|
productBreakdown,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,13 @@ export interface ModelBreakdown {
|
|||||||
cost: number;
|
cost: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductBreakdown {
|
||||||
|
productId: string;
|
||||||
|
tokens: number;
|
||||||
|
requests: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonthlyUsage {
|
export interface MonthlyUsage {
|
||||||
tokens: number;
|
tokens: number;
|
||||||
words: number;
|
words: number;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user