wip share
This commit is contained in:
@@ -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)`,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
298
packages/trpc/src/routers/widget.ts
Normal file
298
packages/trpc/src/routers/widget.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user