54 lines
1.8 KiB
TypeScript
54 lines
1.8 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|||
|
|
import { z } from 'zod';
|
||
|
|
import { and, eq, gte, sql } from 'drizzle-orm';
|
||
|
|
import { getDb, toolUsage } from '@lawdesk/db';
|
||
|
|
|
||
|
|
const TOOL_NAMES = [
|
||
|
|
'hourly-rate-calculator',
|
||
|
|
'case-profitability',
|
||
|
|
'billable-hours-tracker',
|
||
|
|
'document-templates',
|
||
|
|
] as const;
|
||
|
|
|
||
|
|
const logBody = z.object({
|
||
|
|
tool: z.enum(TOOL_NAMES),
|
||
|
|
sessionId: z.string().max(64).optional(),
|
||
|
|
});
|
||
|
|
|
||
|
|
export async function toolUsageRoutes(app: FastifyInstance) {
|
||
|
|
// Log a usage event. Rate-limited per IP so a malicious caller can't pump up "online now" counts.
|
||
|
|
app.post(
|
||
|
|
'/api/tool-usage',
|
||
|
|
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
|
||
|
|
async (req, reply) => {
|
||
|
|
const parsed = logBody.safeParse(req.body);
|
||
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_tool' });
|
||
|
|
await getDb().insert(toolUsage).values({
|
||
|
|
tool: parsed.data.tool,
|
||
|
|
sessionId: parsed.data.sessionId ?? null,
|
||
|
|
ip: req.ip ?? null,
|
||
|
|
});
|
||
|
|
return { ok: true };
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
// Per-tool count of unique sessions in the last 5 minutes — what the public pages display
|
||
|
|
// as "X online". Public route, very cheap query.
|
||
|
|
app.get('/api/tool-usage/online', async () => {
|
||
|
|
const since = new Date(Date.now() - 5 * 60 * 1000);
|
||
|
|
const rows = await getDb()
|
||
|
|
.select({
|
||
|
|
tool: toolUsage.tool,
|
||
|
|
// Distinct (session_id, ip) so multiple page hits from the same browser don't multi-count
|
||
|
|
count: sql<number>`count(distinct coalesce(${toolUsage.sessionId}, host(${toolUsage.ip}::inet)))::int`,
|
||
|
|
})
|
||
|
|
.from(toolUsage)
|
||
|
|
.where(gte(toolUsage.createdAt, since))
|
||
|
|
.groupBy(toolUsage.tool);
|
||
|
|
|
||
|
|
const map: Record<string, number> = {};
|
||
|
|
for (const r of rows) map[r.tool] = r.count;
|
||
|
|
return { online: map, since: since.toISOString() };
|
||
|
|
});
|
||
|
|
}
|