149 lines
4.5 KiB
TypeScript
149 lines
4.5 KiB
TypeScript
import { actor } from "rivetkit";
|
|
import { count, desc, eq, sql } from "drizzle-orm";
|
|
import { db } from "../../db/client.js";
|
|
import {
|
|
growActiveMissions,
|
|
growEvents,
|
|
missionActions,
|
|
} from "../../db/schema.js";
|
|
import { listActiveMissionsPg } from "../../grow/persistence.js";
|
|
import { listMissionActions } from "../../missions/actions.js";
|
|
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../../services/qscore-proxy.js";
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
async function scalarCount(table: any, where?: any) {
|
|
const query = db.select({ value: count() }).from(table);
|
|
const rows = where ? await query.where(where) : await query;
|
|
return rows[0]?.value ?? 0;
|
|
}
|
|
|
|
async function platformAnalytics() {
|
|
const [
|
|
totalEvents,
|
|
serviceEvents,
|
|
missionEvents,
|
|
activeMissions,
|
|
completedMissions,
|
|
totalActions,
|
|
doneActions,
|
|
] = await Promise.all([
|
|
scalarCount(growEvents),
|
|
scalarCount(growEvents, eq(growEvents.category, "service")),
|
|
scalarCount(growEvents, eq(growEvents.category, "mission")),
|
|
scalarCount(growActiveMissions),
|
|
scalarCount(growActiveMissions, eq(growActiveMissions.status, "completed")),
|
|
scalarCount(missionActions),
|
|
scalarCount(missionActions, eq(missionActions.status, "done")),
|
|
]);
|
|
|
|
const serviceUsage = await db
|
|
.select({
|
|
source: growEvents.source,
|
|
type: growEvents.type,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(growEvents)
|
|
.where(eq(growEvents.category, "service"))
|
|
.groupBy(growEvents.source, growEvents.type)
|
|
.orderBy(sql`count(*) desc`)
|
|
.limit(20);
|
|
|
|
return {
|
|
kind: "platform",
|
|
generatedAt: new Date().toISOString(),
|
|
totals: {
|
|
events: totalEvents,
|
|
serviceEvents,
|
|
missionEvents,
|
|
activeMissions,
|
|
completedMissions,
|
|
missionActions: totalActions,
|
|
completedActions: doneActions,
|
|
},
|
|
serviceUsage,
|
|
};
|
|
}
|
|
|
|
async function userQscoreAnalytics(userId: string) {
|
|
const [result, latestEvidence] = await Promise.all([
|
|
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
|
getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
|
]);
|
|
|
|
const breakdown = result?.breakdown ?? {};
|
|
const latestSignals = latestEvidence ?? [];
|
|
const timelineRaw = latestSignals;
|
|
const signalTimeline = timelineRaw.map((signal) => ({
|
|
signalId: signal.signal_id,
|
|
score: signal.score,
|
|
present: signal.present,
|
|
source: signal.source,
|
|
occurredAt: signal.last_occurred_at,
|
|
updatedAt: signal.last_seen_at || null,
|
|
}));
|
|
|
|
return {
|
|
kind: "user-qscore",
|
|
userId,
|
|
generatedAt: new Date().toISOString(),
|
|
current: result
|
|
? {
|
|
rq_score: result.rq_score,
|
|
iq_score: result.iq_score,
|
|
eq_score: result.eq_score,
|
|
sq_score: result.sq_score,
|
|
signalCount: signalTimeline.length,
|
|
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : result.quotients,
|
|
summary: null,
|
|
updatedAt: result.calculated_at || new Date().toISOString(),
|
|
}
|
|
: null,
|
|
latestSignals,
|
|
signalTimeline,
|
|
globalComparison: {
|
|
status: "placeholder",
|
|
percentile: null,
|
|
cohort: null,
|
|
sampleSize: null,
|
|
note: "Global comparison is reserved for the cohort-backed analytics release.",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function userActivityAnalytics(userId: string) {
|
|
const events = await db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(100);
|
|
const activeMissions = await listActiveMissionsPg(userId).catch(() => []);
|
|
const actions = await listMissionActions(userId, { openOnly: false }).catch(() => []);
|
|
|
|
return {
|
|
kind: "user-activity",
|
|
userId,
|
|
generatedAt: new Date().toISOString(),
|
|
events,
|
|
activeMissions: activeMissions.map((item) => item.mission),
|
|
actions,
|
|
};
|
|
}
|
|
|
|
export const analyticsActor = actor({
|
|
options: { name: "Analytics Actor", icon: "chart-no-axes-column", noSleep: true },
|
|
state: { updatedAt: Date.now() },
|
|
actions: {
|
|
getPlatform: async (c) => {
|
|
c.state.updatedAt = Date.now();
|
|
return platformAnalytics();
|
|
},
|
|
getUserQscore: async (c, input: { userId: string }) => {
|
|
c.state.updatedAt = Date.now();
|
|
return userQscoreAnalytics(input.userId);
|
|
},
|
|
getUserActivity: async (c, input: { userId: string }) => {
|
|
c.state.updatedAt = Date.now();
|
|
return userActivityAnalytics(input.userId);
|
|
},
|
|
},
|
|
});
|