feat: insights

* fix: migration for newly created self-hosting instances

* fix: build script

* wip

* wip

* wip

* fix: tailwind css
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-19 09:37:15 +01:00
committed by GitHub
parent 1e4f02fb5e
commit 5f38560373
48 changed files with 4072 additions and 25 deletions

View File

@@ -5,6 +5,7 @@ import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard';
import { eventRouter } from './routers/event';
import { importRouter } from './routers/import';
import { insightRouter } from './routers/insight';
import { integrationRouter } from './routers/integration';
import { notificationRouter } from './routers/notification';
import { onboardingRouter } from './routers/onboarding';
@@ -47,6 +48,7 @@ export const appRouter = createTRPCRouter({
overview: overviewRouter,
realtime: realtimeRouter,
chat: chatRouter,
insight: insightRouter,
});
// export type definition of API

View File

@@ -0,0 +1,102 @@
import { db } from '@openpanel/db';
import { z } from 'zod';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const insightRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().min(1).max(100).optional().default(50),
}),
)
.query(async ({ input: { projectId, limit }, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
// Fetch more insights than needed to account for deduplication
const allInsights = await db.projectInsight.findMany({
where: {
projectId,
state: 'active',
moduleKey: {
notIn: ['page-trends', 'entry-pages'],
},
},
orderBy: {
impactScore: 'desc',
},
take: limit * 3, // Fetch 3x to account for deduplication
});
// WindowKind priority: yesterday (1) > rolling_7d (2) > rolling_30d (3)
const windowKindPriority: Record<string, number> = {
yesterday: 1,
rolling_7d: 2,
rolling_30d: 3,
};
// Group by moduleKey + dimensionKey, keep only highest priority windowKind
const deduplicated = new Map<string, (typeof allInsights)[0]>();
for (const insight of allInsights) {
const key = `${insight.moduleKey}:${insight.dimensionKey}`;
const existing = deduplicated.get(key);
const currentPriority = windowKindPriority[insight.windowKind] ?? 999;
const existingPriority = existing
? (windowKindPriority[existing.windowKind] ?? 999)
: 999;
// Keep if no existing, or if current has higher priority (lower number)
if (!existing || currentPriority < existingPriority) {
deduplicated.set(key, insight);
}
}
// Convert back to array, sort by impactScore, and limit
const insights = Array.from(deduplicated.values())
.sort((a, b) => (b.impactScore ?? 0) - (a.impactScore ?? 0))
.slice(0, limit)
.map(({ impactScore, ...rest }) => rest); // Remove impactScore from response
return insights;
}),
listAll: protectedProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().min(1).max(500).optional().default(200),
}),
)
.query(async ({ input: { projectId, limit }, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const insights = await db.projectInsight.findMany({
where: {
projectId,
state: 'active',
},
orderBy: {
impactScore: 'desc',
},
take: limit,
});
return insights;
}),
});