wip share

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-14 08:59:25 +01:00
parent 13bd16b207
commit f880b9a697
19 changed files with 1577 additions and 399 deletions

View File

@@ -23,9 +23,13 @@ export async function up() {
for (const report of reports) {
const currentOptions = report.options as IReportOptions | null | undefined;
// Skip if options already exists and is valid
if (currentOptions && typeof currentOptions === 'object' && 'type' in currentOptions) {
if (
currentOptions &&
typeof currentOptions === 'object' &&
'type' in currentOptions
) {
skippedCount++;
continue;
}
@@ -61,7 +65,7 @@ export async function up() {
console.log(
`Migrating report ${report.name} (${report.id}) - chartType: ${report.chartType}`,
);
await db.report.update({
where: { id: report.id },
data: {
@@ -84,5 +88,3 @@ export async function up() {
`Skipped: ${skippedCount} reports (already migrated or no legacy fields)`,
]);
}

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "public"."share_widgets" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"public" BOOLEAN NOT NULL DEFAULT true,
"options" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "share_widgets_id_key" ON "public"."share_widgets"("id");
-- AddForeignKey
ALTER TABLE "public"."share_widgets" ADD CONSTRAINT "share_widgets_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."share_widgets" ADD CONSTRAINT "share_widgets_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -58,6 +58,7 @@ model Organization {
ShareOverview ShareOverview[]
ShareDashboard ShareDashboard[]
ShareReport ShareReport[]
ShareWidget ShareWidget[]
integrations Integration[]
invites Invite[]
timezone String?
@@ -193,6 +194,7 @@ model Project {
share ShareOverview?
shareDashboards ShareDashboard[]
shareReports ShareReport[]
shareWidgets ShareWidget[]
meta EventMeta[]
references Reference[]
access ProjectAccess[]
@@ -410,6 +412,21 @@ model ShareReport {
@@map("share_reports")
}
model ShareWidget {
id String @unique
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
public Boolean @default(true)
/// [IPrismaWidgetOptions]
options Json
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("share_widgets")
}
model EventMeta {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String

View File

@@ -3,6 +3,7 @@ import type {
IIntegrationConfig,
INotificationRuleConfig,
IProjectFilters,
IWidgetOptions,
InsightPayload,
} from '@openpanel/validation';
import type {
@@ -20,6 +21,7 @@ declare global {
type IPrismaNotificationPayload = INotificationPayload;
type IPrismaProjectFilters = IProjectFilters[];
type IPrismaProjectInsightPayload = InsightPayload;
type IPrismaWidgetOptions = IWidgetOptions;
type IPrismaClickhouseEvent = IClickhouseEvent;
type IPrismaClickhouseProfile = IClickhouseProfile;
type IPrismaClickhouseBotEvent = IClickhouseBotEvent;

View File

@@ -20,6 +20,7 @@ import { sessionRouter } from './routers/session';
import { shareRouter } from './routers/share';
import { subscriptionRouter } from './routers/subscription';
import { userRouter } from './routers/user';
import { widgetRouter } from './routers/widget';
import { createTRPCRouter } from './trpc';
/**
* This is the primary router for your server.
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
realtime: realtimeRouter,
chat: chatRouter,
insight: insightRouter,
widget: widgetRouter,
});
// export type definition of API

View File

@@ -0,0 +1,298 @@
import ShortUniqueId from 'short-unique-id';
import { z } from 'zod';
import {
TABLE_NAMES,
ch,
clix,
db,
eventBuffer,
getSettingsForProject,
} from '@openpanel/db';
import {
zCounterWidgetOptions,
zRealtimeWidgetOptions,
zWidgetOptions,
zWidgetType,
} from '@openpanel/validation';
import { TRPCNotFoundError } from '../errors';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
const uid = new ShortUniqueId({ length: 6 });
// Helper to find widget by projectId and type
async function findWidgetByType(projectId: string, type: string) {
const widgets = await db.shareWidget.findMany({
where: { projectId },
});
return widgets.find(
(w) => (w.options as z.infer<typeof zWidgetOptions>)?.type === type,
);
}
export const widgetRouter = createTRPCRouter({
// Get widget by projectId and type (returns null if not found or not public)
get: protectedProcedure
.input(z.object({ projectId: z.string(), type: zWidgetType }))
.query(async ({ input }) => {
const widget = await findWidgetByType(input.projectId, input.type);
if (!widget) {
return null;
}
return widget;
}),
// Toggle widget public status (creates if doesn't exist)
toggle: protectedProcedure
.input(
z.object({
projectId: z.string(),
organizationId: z.string(),
type: zWidgetType,
enabled: z.boolean(),
}),
)
.mutation(async ({ input }) => {
const existing = await findWidgetByType(input.projectId, input.type);
if (existing) {
return db.shareWidget.update({
where: { id: existing.id },
data: { public: input.enabled },
});
}
// Create new widget with default options
const defaultOptions =
input.type === 'realtime'
? {
type: 'realtime' as const,
referrers: true,
countries: true,
paths: false,
}
: { type: 'counter' as const };
return db.shareWidget.create({
data: {
id: uid.rnd(),
projectId: input.projectId,
organizationId: input.organizationId,
public: input.enabled,
options: defaultOptions,
},
});
}),
// Update widget options (for realtime widget)
updateOptions: protectedProcedure
.input(
z.object({
projectId: z.string(),
organizationId: z.string(),
options: zWidgetOptions,
}),
)
.mutation(async ({ input }) => {
const existing = await findWidgetByType(
input.projectId,
input.options.type,
);
if (existing) {
return db.shareWidget.update({
where: { id: existing.id },
data: { options: input.options },
});
}
// Create new widget if it doesn't exist
return db.shareWidget.create({
data: {
id: uid.rnd(),
projectId: input.projectId,
organizationId: input.organizationId,
public: false,
options: input.options,
},
});
}),
counter: publicProcedure
.input(z.object({ shareId: z.string() }))
.query(async ({ input }) => {
const widget = await db.shareWidget.findUnique({
where: {
id: input.shareId,
},
});
if (!widget || !widget.public) {
throw TRPCNotFoundError('Widget not found');
}
if (widget.options.type !== 'counter') {
throw TRPCNotFoundError('Invalid widget type');
}
return {
projectId: widget.projectId,
counter: await eventBuffer.getActiveVisitorCount(widget.projectId),
};
}),
realtimeData: publicProcedure
.input(z.object({ shareId: z.string() }))
.query(async ({ input }) => {
// Validate ShareWidget exists and is public
const widget = await db.shareWidget.findUnique({
where: {
id: input.shareId,
},
include: {
project: {
select: {
domain: true,
name: true,
},
},
},
});
if (!widget || !widget.public) {
throw TRPCNotFoundError('Widget not found');
}
const { projectId, options } = widget;
if (options.type !== 'realtime') {
throw TRPCNotFoundError('Invalid widget type');
}
const { timezone } = await getSettingsForProject(projectId);
// Always fetch live count and histogram
const totalSessionsQuery = clix(ch, timezone)
.select<{ total_sessions: number }>([
'uniq(session_id) as total_sessions',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'));
const minuteCountsQuery = clix(ch, timezone)
.select<{
minute: string;
session_count: number;
visitor_count: number;
}>([
`${clix.toStartOf('created_at', 'minute')} as minute`,
'uniq(session_id) as session_count',
'uniq(profile_id) as visitor_count',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.groupBy(['minute'])
.orderBy('minute', 'ASC')
.fill(
clix.exp('toStartOfMinute(now() - INTERVAL 30 MINUTE)'),
clix.exp('toStartOfMinute(now())'),
clix.exp('INTERVAL 1 MINUTE'),
);
// Conditionally fetch countries
const countriesQueryPromise = options.countries
? clix(ch, timezone)
.select<{
country: string;
count: number;
}>(['country', 'uniq(session_id) as count'])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.where('country', '!=', '')
.where('country', 'IS NOT NULL')
.groupBy(['country'])
.orderBy('count', 'DESC')
.limit(10)
.execute()
: Promise.resolve<Array<{ country: string; count: number }>>([]);
// Conditionally fetch referrers
const referrersQueryPromise = options.referrers
? clix(ch, timezone)
.select<{ referrer: string; count: number }>([
'referrer_name as referrer',
'uniq(session_id) as count',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.where('referrer_name', '!=', '')
.where('referrer_name', 'IS NOT NULL')
.groupBy(['referrer_name'])
.orderBy('count', 'DESC')
.limit(10)
.execute()
: Promise.resolve<Array<{ referrer: string; count: number }>>([]);
// Conditionally fetch paths
const pathsQueryPromise = options.paths
? clix(ch, timezone)
.select<{ path: string; count: number }>([
'path',
'uniq(session_id) as count',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.where('path', '!=', '')
.where('path', 'IS NOT NULL')
.groupBy(['path'])
.orderBy('count', 'DESC')
.limit(10)
.execute()
: Promise.resolve<Array<{ path: string; count: number }>>([]);
const [totalSessions, minuteCounts, countries, referrers, paths] =
await Promise.all([
totalSessionsQuery.execute(),
minuteCountsQuery.execute(),
countriesQueryPromise,
referrersQueryPromise,
pathsQueryPromise,
]);
return {
projectId,
liveCount: totalSessions[0]?.total_sessions || 0,
project: widget.project,
histogram: minuteCounts.map((item) => ({
minute: item.minute,
sessionCount: item.session_count,
visitorCount: item.visitor_count,
timestamp: new Date(item.minute).getTime(),
time: new Date(item.minute).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
})),
countries: countries.map((item) => ({
country: item.country,
count: item.count,
})),
referrers: referrers.map((item) => ({
referrer: item.referrer,
count: item.count,
})),
paths: paths.map((item) => ({
path: item.path,
count: item.count,
})),
};
}),
});

View File

@@ -135,6 +135,29 @@ export const zReportOptions = z.discriminatedUnion('type', [
export type IReportOptions = z.infer<typeof zReportOptions>;
export type ISankeyOptions = z.infer<typeof zSankeyOptions>;
export const zWidgetType = z.enum(['realtime', 'counter']);
export type IWidgetType = z.infer<typeof zWidgetType>;
export const zRealtimeWidgetOptions = z.object({
type: z.literal('realtime'),
referrers: z.boolean().default(true),
countries: z.boolean().default(true),
paths: z.boolean().default(false),
});
export const zCounterWidgetOptions = z.object({
type: z.literal('counter'),
});
export const zWidgetOptions = z.discriminatedUnion('type', [
zRealtimeWidgetOptions,
zCounterWidgetOptions,
]);
export type IWidgetOptions = z.infer<typeof zWidgetOptions>;
export type ICounterWidgetOptions = z.infer<typeof zCounterWidgetOptions>;
export type IRealtimeWidgetOptions = z.infer<typeof zRealtimeWidgetOptions>;
// Base input schema - for API calls, engine, chart queries
export const zReportInput = z.object({
projectId: z.string().describe('The ID of the project this chart belongs to'),
@@ -193,7 +216,9 @@ export const zReportInput = z.object({
.describe('Chart-specific options (funnel, retention, sankey)'),
// Optional display fields
name: z.string().optional().describe('The user-defined name for the report'),
lineType: zLineType.optional().describe('The visual style of the line in the chart'),
lineType: zLineType
.optional()
.describe('The visual style of the line in the chart'),
unit: z
.string()
.optional()
@@ -204,8 +229,13 @@ export const zReportInput = z.object({
// Complete report schema - for saved reports
export const zReport = zReportInput.extend({
name: z.string().default('Untitled').describe('The user-defined name for the report'),
lineType: zLineType.default('monotone').describe('The visual style of the line in the chart'),
name: z
.string()
.default('Untitled')
.describe('The user-defined name for the report'),
lineType: zLineType
.default('monotone')
.describe('The visual style of the line in the chart'),
});
// Alias for backward compatibility