feat: share dashboard & reports, sankey report, new widgets
* fix: prompt card shadows on light mode * fix: handle past_due and unpaid from polar * wip * wip * wip 1 * fix: improve types for chart/reports * wip share
This commit is contained in:
committed by
GitHub
parent
39251c8598
commit
ed1c57dbb8
@@ -109,6 +109,7 @@ export const chartTypes = {
|
||||
funnel: 'Funnel',
|
||||
retention: 'Retention',
|
||||
conversion: 'Conversion',
|
||||
sankey: 'Sankey',
|
||||
} as const;
|
||||
|
||||
export const chartSegments = {
|
||||
|
||||
90
packages/db/code-migrations/9-migrate-options.ts
Normal file
90
packages/db/code-migrations/9-migrate-options.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { IReportOptions } from '@openpanel/validation';
|
||||
import { db } from '../index';
|
||||
import { printBoxMessage } from './helpers';
|
||||
|
||||
export async function up() {
|
||||
printBoxMessage('🔄 Migrating Legacy Fields to Options', []);
|
||||
|
||||
// Get all reports
|
||||
const reports = await db.report.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
chartType: true,
|
||||
funnelGroup: true,
|
||||
funnelWindow: true,
|
||||
criteria: true,
|
||||
options: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
let migratedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
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
|
||||
) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let newOptions: IReportOptions | null = null;
|
||||
|
||||
// Migrate based on chart type
|
||||
if (report.chartType === 'funnel') {
|
||||
// Only create options if we have legacy fields to migrate
|
||||
if (report.funnelGroup || report.funnelWindow !== null) {
|
||||
newOptions = {
|
||||
type: 'funnel',
|
||||
funnelGroup: report.funnelGroup ?? undefined,
|
||||
funnelWindow: report.funnelWindow ?? undefined,
|
||||
};
|
||||
}
|
||||
} else if (report.chartType === 'retention') {
|
||||
// Only create options if we have criteria to migrate
|
||||
if (report.criteria) {
|
||||
newOptions = {
|
||||
type: 'retention',
|
||||
criteria: report.criteria as 'on_or_after' | 'on' | undefined,
|
||||
};
|
||||
}
|
||||
} else if (report.chartType === 'sankey') {
|
||||
// Sankey should already have options, but if not, skip
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only update if we have new options to set
|
||||
if (newOptions) {
|
||||
console.log(
|
||||
`Migrating report ${report.name} (${report.id}) - chartType: ${report.chartType}`,
|
||||
);
|
||||
|
||||
await db.report.update({
|
||||
where: { id: report.id },
|
||||
data: {
|
||||
options: newOptions,
|
||||
// Set legacy fields to null after migration
|
||||
funnelGroup: null,
|
||||
funnelWindow: null,
|
||||
criteria: report.chartType === 'retention' ? null : report.criteria,
|
||||
},
|
||||
});
|
||||
|
||||
migratedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
printBoxMessage('✅ Migration Complete', [
|
||||
`Migrated: ${migratedCount} reports`,
|
||||
`Skipped: ${skippedCount} reports (already migrated or no legacy fields)`,
|
||||
]);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export * from './src/services/share.service';
|
||||
export * from './src/services/session.service';
|
||||
export * from './src/services/funnel.service';
|
||||
export * from './src/services/conversion.service';
|
||||
export * from './src/services/sankey.service';
|
||||
export * from './src/services/user.service';
|
||||
export * from './src/services/reference.service';
|
||||
export * from './src/services/id.service';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."ChartType" ADD VALUE 'sankey';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."reports" ADD COLUMN "options" JSONB;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."share_dashboards" (
|
||||
"id" TEXT NOT NULL,
|
||||
"dashboardId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"public" BOOLEAN NOT NULL DEFAULT false,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."share_reports" (
|
||||
"id" TEXT NOT NULL,
|
||||
"reportId" UUID NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"public" BOOLEAN NOT NULL DEFAULT false,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_dashboards_id_key" ON "public"."share_dashboards"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_dashboards_dashboardId_key" ON "public"."share_dashboards"("dashboardId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_reports_id_key" ON "public"."share_reports"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_reports_reportId_key" ON "public"."share_reports"("reportId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."dashboards"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "public"."reports"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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;
|
||||
@@ -46,16 +46,19 @@ model Chat {
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
projects Project[]
|
||||
members Member[]
|
||||
createdByUserId String?
|
||||
createdBy User? @relation(name: "organizationCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
createdBy User? @relation(name: "organizationCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
ProjectAccess ProjectAccess[]
|
||||
Client Client[]
|
||||
Dashboard Dashboard[]
|
||||
ShareOverview ShareOverview[]
|
||||
ShareDashboard ShareDashboard[]
|
||||
ShareReport ShareReport[]
|
||||
ShareWidget ShareWidget[]
|
||||
integrations Integration[]
|
||||
invites Invite[]
|
||||
timezone String?
|
||||
@@ -185,13 +188,16 @@ model Project {
|
||||
/// [IPrismaProjectFilters]
|
||||
filters Json @default("[]")
|
||||
|
||||
clients Client[]
|
||||
reports Report[]
|
||||
dashboards Dashboard[]
|
||||
share ShareOverview?
|
||||
meta EventMeta[]
|
||||
references Reference[]
|
||||
access ProjectAccess[]
|
||||
clients Client[]
|
||||
reports Report[]
|
||||
dashboards Dashboard[]
|
||||
share ShareOverview?
|
||||
shareDashboards ShareDashboard[]
|
||||
shareReports ShareReport[]
|
||||
shareWidgets ShareWidget[]
|
||||
meta EventMeta[]
|
||||
references Reference[]
|
||||
access ProjectAccess[]
|
||||
|
||||
notificationRules NotificationRule[]
|
||||
notifications Notification[]
|
||||
@@ -279,16 +285,18 @@ enum ChartType {
|
||||
funnel
|
||||
retention
|
||||
conversion
|
||||
sankey
|
||||
}
|
||||
|
||||
model Dashboard {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
reports Report[]
|
||||
share ShareDashboard?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -321,10 +329,13 @@ model Report {
|
||||
criteria String?
|
||||
funnelGroup String?
|
||||
funnelWindow Float?
|
||||
/// [IReportOptions]
|
||||
options Json?
|
||||
|
||||
dashboardId String
|
||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||
layout ReportLayout?
|
||||
share ShareReport?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -369,6 +380,53 @@ model ShareOverview {
|
||||
@@map("shares")
|
||||
}
|
||||
|
||||
model ShareDashboard {
|
||||
id String @unique
|
||||
dashboardId String @unique
|
||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("share_dashboards")
|
||||
}
|
||||
|
||||
model ShareReport {
|
||||
id String @unique
|
||||
reportId String @unique @db.Uuid
|
||||
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@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
|
||||
|
||||
@@ -203,6 +203,13 @@ export class Query<T = any> {
|
||||
return this;
|
||||
}
|
||||
|
||||
rawHaving(condition: string): this {
|
||||
if (condition) {
|
||||
this._having.push({ condition, operator: 'AND' });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
andHaving(column: string, operator: Operator, value: SqlParam): this {
|
||||
const condition = this.buildCondition(column, operator, value);
|
||||
this._having.push({ condition, operator: 'AND' });
|
||||
|
||||
@@ -53,9 +53,6 @@ export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
|
||||
previous: plan.input.previous ?? false,
|
||||
limit: plan.input.limit,
|
||||
offset: plan.input.offset,
|
||||
criteria: plan.input.criteria,
|
||||
funnelGroup: plan.input.funnelGroup,
|
||||
funnelWindow: plan.input.funnelWindow,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
|
||||
@@ -4,7 +4,7 @@ import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
FinalChart,
|
||||
IChartEventItem,
|
||||
IChartInput,
|
||||
IReportInput,
|
||||
} from '@openpanel/validation';
|
||||
import { chQuery } from '../clickhouse/client';
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ import type { ConcreteSeries } from './types';
|
||||
* Chart Engine - Main entry point
|
||||
* Executes the pipeline: normalize -> plan -> fetch -> compute -> format
|
||||
*/
|
||||
export async function executeChart(input: IChartInput): Promise<FinalChart> {
|
||||
export async function executeChart(input: IReportInput): Promise<FinalChart> {
|
||||
// Stage 1: Normalize input
|
||||
const normalized = await normalize(input);
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function executeChart(input: IChartInput): Promise<FinalChart> {
|
||||
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
|
||||
*/
|
||||
export async function executeAggregateChart(
|
||||
input: IChartInput,
|
||||
input: IReportInput,
|
||||
): Promise<FinalChart> {
|
||||
// Stage 1: Normalize input
|
||||
const normalized = await normalize(input);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartInput,
|
||||
IChartInputWithDates,
|
||||
IReportInput,
|
||||
IReportInputWithDates,
|
||||
} from '@openpanel/validation';
|
||||
import { getChartStartEndDate } from '../services/chart.service';
|
||||
import { getSettingsForProject } from '../services/organization.service';
|
||||
@@ -15,8 +15,8 @@ export type NormalizedInput = Awaited<ReturnType<typeof normalize>>;
|
||||
* Normalize a chart input into a clean structure with dates and normalized series
|
||||
*/
|
||||
export async function normalize(
|
||||
input: IChartInput,
|
||||
): Promise<IChartInputWithDates & { series: SeriesDefinition[] }> {
|
||||
input: IReportInput,
|
||||
): Promise<IReportInputWithDates & { series: SeriesDefinition[] }> {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(
|
||||
{
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
IChartEventFilter,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartInput,
|
||||
IChartInputWithDates,
|
||||
IReportInput,
|
||||
IReportInputWithDates,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
/**
|
||||
@@ -50,7 +50,7 @@ export type ConcreteSeries = {
|
||||
export type Plan = {
|
||||
concreteSeries: ConcreteSeries[];
|
||||
definitions: SeriesDefinition[];
|
||||
input: IChartInputWithDates;
|
||||
input: IReportInputWithDates;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import sqlstring from 'sqlstring';
|
||||
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
|
||||
import type {
|
||||
IChartEventFilter,
|
||||
IChartInput,
|
||||
IReportInput,
|
||||
IChartRange,
|
||||
IGetChartDataInput,
|
||||
} from '@openpanel/validation';
|
||||
@@ -973,7 +973,7 @@ export function getChartStartEndDate(
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
|
||||
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
|
||||
timezone: string,
|
||||
) {
|
||||
if (startDate && endDate) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||
import type { IChartEvent, IChartBreakdown, IReportInput } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||
import { clix } from '../clickhouse/query-builder';
|
||||
@@ -16,21 +16,23 @@ export class ConversionService {
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
funnelGroup,
|
||||
funnelWindow = 24,
|
||||
options,
|
||||
series,
|
||||
breakdowns = [],
|
||||
limit,
|
||||
interval,
|
||||
timezone,
|
||||
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
|
||||
}: Omit<IReportInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
|
||||
timezone: string;
|
||||
}) {
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
const funnelGroup = funnelOptions?.funnelGroup;
|
||||
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
|
||||
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
||||
const breakdownColumns = breakdowns.map(
|
||||
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||
(b: IChartBreakdown, index: number) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||
);
|
||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||
const breakdownGroupBy = breakdowns.map((b: IChartBreakdown, index: number) => `b_${index}`);
|
||||
|
||||
const events = onlyReportEvents(series);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ifNaN } from '@openpanel/common';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartInput,
|
||||
IReportInput,
|
||||
} from '@openpanel/validation';
|
||||
import { last, reverse, uniq } from 'ramda';
|
||||
import sqlstring from 'sqlstring';
|
||||
@@ -185,16 +185,19 @@ export class FunnelService {
|
||||
startDate,
|
||||
endDate,
|
||||
series,
|
||||
funnelWindow = 24,
|
||||
funnelGroup,
|
||||
options,
|
||||
breakdowns = [],
|
||||
limit,
|
||||
timezone = 'UTC',
|
||||
}: IChartInput & { timezone: string; events?: IChartEvent[] }) {
|
||||
}: IReportInput & { timezone: string; events?: IChartEvent[] }) {
|
||||
if (!startDate || !endDate) {
|
||||
throw new Error('startDate and endDate are required');
|
||||
}
|
||||
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
|
||||
const funnelGroup = funnelOptions?.funnelGroup;
|
||||
|
||||
const eventSeries = onlyReportEvents(series);
|
||||
|
||||
if (eventSeries.length === 0) {
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
IChartEventFilter,
|
||||
IChartEventItem,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
ICriteria,
|
||||
IReport,
|
||||
IReportOptions,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import type { Report as DbReport, ReportLayout } from '../prisma-client';
|
||||
@@ -65,17 +65,22 @@ export function transformReportEventItem(
|
||||
|
||||
export function transformReport(
|
||||
report: DbReport & { layout?: ReportLayout | null },
|
||||
): IChartProps & { id: string; layout?: ReportLayout | null } {
|
||||
): IReport & {
|
||||
id: string;
|
||||
layout?: ReportLayout | null;
|
||||
} {
|
||||
const options = report.options as IReportOptions | null | undefined;
|
||||
|
||||
return {
|
||||
id: report.id,
|
||||
projectId: report.projectId,
|
||||
series:
|
||||
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
|
||||
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||
name: report.name || 'Untitled',
|
||||
chartType: report.chartType,
|
||||
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,
|
||||
interval: report.interval,
|
||||
name: report.name || 'Untitled',
|
||||
series:
|
||||
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
|
||||
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||
range:
|
||||
report.range in deprecated_timeRanges
|
||||
? '30d'
|
||||
@@ -84,10 +89,8 @@ export function transformReport(
|
||||
formula: report.formula ?? undefined,
|
||||
metric: report.metric ?? 'sum',
|
||||
unit: report.unit ?? undefined,
|
||||
criteria: (report.criteria as ICriteria) ?? undefined,
|
||||
funnelGroup: report.funnelGroup ?? undefined,
|
||||
funnelWindow: report.funnelWindow ?? undefined,
|
||||
layout: report.layout ?? undefined,
|
||||
options: options ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
783
packages/db/src/services/sankey.service.ts
Normal file
783
packages/db/src/services/sankey.service.ts
Normal file
@@ -0,0 +1,783 @@
|
||||
import { chartColors } from '@openpanel/constants';
|
||||
import { type IChartEventFilter, zChartEvent } from '@openpanel/validation';
|
||||
import { z } from 'zod';
|
||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||
import { clix } from '../clickhouse/query-builder';
|
||||
import { getEventFiltersWhereClause } from './chart.service';
|
||||
|
||||
export const zGetSankeyInput = z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
steps: z.number().min(2).max(10).default(5),
|
||||
mode: z.enum(['between', 'after', 'before']),
|
||||
startEvent: zChartEvent,
|
||||
endEvent: zChartEvent.optional(),
|
||||
exclude: z.array(z.string()).default([]),
|
||||
include: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type IGetSankeyInput = z.infer<typeof zGetSankeyInput> & {
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
export class SankeyService {
|
||||
constructor(private client: typeof ch) {}
|
||||
|
||||
getRawWhereClause(type: 'events' | 'sessions', filters: IChartEventFilter[]) {
|
||||
const where = getEventFiltersWhereClause(
|
||||
filters.map((item) => {
|
||||
if (type === 'sessions') {
|
||||
if (item.name === 'path') {
|
||||
return { ...item, name: 'entry_path' };
|
||||
}
|
||||
if (item.name === 'origin') {
|
||||
return { ...item, name: 'entry_origin' };
|
||||
}
|
||||
if (item.name.startsWith('properties.__query.utm_')) {
|
||||
return {
|
||||
...item,
|
||||
name: item.name.replace('properties.__query.utm_', 'utm_'),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.values(where).join(' AND ');
|
||||
}
|
||||
|
||||
private buildEventNameFilter(
|
||||
include: string[] | undefined,
|
||||
exclude: string[],
|
||||
startEventName: string | undefined,
|
||||
endEventName: string | undefined,
|
||||
) {
|
||||
if (include && include.length > 0) {
|
||||
const eventNames = [...include, startEventName, endEventName]
|
||||
.filter((item) => item !== undefined)
|
||||
.map((e) => `'${e!.replace(/'/g, "''")}'`)
|
||||
.join(', ');
|
||||
return `name IN (${eventNames})`;
|
||||
}
|
||||
if (exclude.length > 0) {
|
||||
const excludedNames = exclude
|
||||
.map((e) => `'${e.replace(/'/g, "''")}'`)
|
||||
.join(', ');
|
||||
return `name NOT IN (${excludedNames})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildSessionEventCTE(
|
||||
event: z.infer<typeof zChartEvent>,
|
||||
projectId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
timezone: string,
|
||||
): ReturnType<typeof clix> {
|
||||
return clix(this.client, timezone)
|
||||
.select<{ session_id: string }>(['session_id'])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', event.name)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(this.getRawWhereClause('events', event.filters))
|
||||
.groupBy(['session_id']);
|
||||
}
|
||||
|
||||
private getModeConfig(
|
||||
mode: 'after' | 'before' | 'between',
|
||||
startEvent: z.infer<typeof zChartEvent> | undefined,
|
||||
endEvent: z.infer<typeof zChartEvent> | undefined,
|
||||
hasStartEventCTE: boolean,
|
||||
hasEndEventCTE: boolean,
|
||||
steps: number,
|
||||
): { sessionFilter: string; eventsSliceExpr: string } {
|
||||
const defaultSliceExpr = `arraySlice(events_deduped, 1, ${steps})`;
|
||||
|
||||
if (mode === 'after' && startEvent) {
|
||||
const escapedStartEvent = startEvent.name.replace(/'/g, "''");
|
||||
const sessionFilter = hasStartEventCTE
|
||||
? 'session_id IN (SELECT session_id FROM start_event_sessions)'
|
||||
: `arrayExists(x -> x = '${escapedStartEvent}', events_deduped)`;
|
||||
const eventsSliceExpr = `arraySlice(events_deduped, arrayFirstIndex(x -> x = '${escapedStartEvent}', events_deduped), ${steps})`;
|
||||
return { sessionFilter, eventsSliceExpr };
|
||||
}
|
||||
|
||||
if (mode === 'before' && startEvent) {
|
||||
const escapedStartEvent = startEvent.name.replace(/'/g, "''");
|
||||
const sessionFilter = hasStartEventCTE
|
||||
? 'session_id IN (SELECT session_id FROM start_event_sessions)'
|
||||
: `arrayExists(x -> x = '${escapedStartEvent}', events_deduped)`;
|
||||
const eventsSliceExpr = `arraySlice(
|
||||
events_deduped,
|
||||
greatest(1, arrayFirstIndex(x -> x = '${escapedStartEvent}', events_deduped) - ${steps} + 1),
|
||||
arrayFirstIndex(x -> x = '${escapedStartEvent}', events_deduped) - greatest(1, arrayFirstIndex(x -> x = '${escapedStartEvent}', events_deduped) - ${steps} + 1) + 1
|
||||
)`;
|
||||
return { sessionFilter, eventsSliceExpr };
|
||||
}
|
||||
|
||||
if (mode === 'between' && startEvent && endEvent) {
|
||||
const escapedStartEvent = startEvent.name.replace(/'/g, "''");
|
||||
const escapedEndEvent = endEvent.name.replace(/'/g, "''");
|
||||
let sessionFilter = '';
|
||||
if (hasStartEventCTE && hasEndEventCTE) {
|
||||
sessionFilter =
|
||||
'session_id IN (SELECT session_id FROM start_event_sessions) AND session_id IN (SELECT session_id FROM end_event_sessions)';
|
||||
} else if (hasStartEventCTE) {
|
||||
sessionFilter = `session_id IN (SELECT session_id FROM start_event_sessions) AND arrayExists(x -> x = '${escapedEndEvent}', events_deduped)`;
|
||||
} else if (hasEndEventCTE) {
|
||||
sessionFilter = `arrayExists(x -> x = '${escapedStartEvent}', events_deduped) AND session_id IN (SELECT session_id FROM end_event_sessions)`;
|
||||
} else {
|
||||
sessionFilter = `arrayExists(x -> x = '${escapedStartEvent}', events_deduped) AND arrayExists(x -> x = '${escapedEndEvent}', events_deduped)`;
|
||||
}
|
||||
return { sessionFilter, eventsSliceExpr: defaultSliceExpr };
|
||||
}
|
||||
|
||||
return { sessionFilter: '', eventsSliceExpr: defaultSliceExpr };
|
||||
}
|
||||
|
||||
private async executeBetweenMode(
|
||||
sessionPathsQuery: ReturnType<typeof clix>,
|
||||
startEvent: z.infer<typeof zChartEvent>,
|
||||
endEvent: z.infer<typeof zChartEvent>,
|
||||
steps: number,
|
||||
COLORS: string[],
|
||||
timezone: string,
|
||||
): Promise<{
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
nodeColor: string;
|
||||
percentage?: number;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}>;
|
||||
links: Array<{ source: string; target: string; value: number }>;
|
||||
}> {
|
||||
// Find sessions where startEvent comes before endEvent
|
||||
const betweenSessionsQuery = clix(this.client, timezone)
|
||||
.with('session_paths', sessionPathsQuery)
|
||||
.select<{
|
||||
session_id: string;
|
||||
events: string[];
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
}>([
|
||||
'session_id',
|
||||
'events',
|
||||
`arrayFirstIndex(x -> x = '${startEvent.name.replace(/'/g, "''")}', events) as start_index`,
|
||||
`arrayFirstIndex(x -> x = '${endEvent.name.replace(/'/g, "''")}', events) as end_index`,
|
||||
])
|
||||
.from('session_paths')
|
||||
.having('start_index', '>', 0)
|
||||
.having('end_index', '>', 0)
|
||||
.rawHaving('start_index < end_index');
|
||||
|
||||
// Get the slice between start and end
|
||||
const betweenPathsQuery = clix(this.client, timezone)
|
||||
.with('between_sessions', betweenSessionsQuery)
|
||||
.select<{
|
||||
session_id: string;
|
||||
events: string[];
|
||||
entry_event: string;
|
||||
}>([
|
||||
'session_id',
|
||||
'arraySlice(events, start_index, end_index - start_index + 1) as events',
|
||||
'events[start_index] as entry_event',
|
||||
])
|
||||
.from('between_sessions');
|
||||
|
||||
// Get top entry events
|
||||
const topEntriesQuery = clix(this.client, timezone)
|
||||
.with('session_paths', betweenPathsQuery)
|
||||
.select<{ entry_event: string; count: number }>([
|
||||
'entry_event',
|
||||
'count() as count',
|
||||
])
|
||||
.from('session_paths')
|
||||
.groupBy(['entry_event'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(3);
|
||||
|
||||
const topEntries = await topEntriesQuery.execute();
|
||||
|
||||
if (topEntries.length === 0) {
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
const topEntryEvents = topEntries.map((e) => e.entry_event);
|
||||
const totalSessions = topEntries.reduce((sum, e) => sum + e.count, 0);
|
||||
|
||||
// Get transitions for between mode
|
||||
const transitionsQuery = clix(this.client, timezone)
|
||||
.with('between_sessions', betweenSessionsQuery)
|
||||
.with(
|
||||
'session_paths',
|
||||
clix(this.client, timezone)
|
||||
.select([
|
||||
'session_id',
|
||||
'arraySlice(events, start_index, end_index - start_index + 1) as events',
|
||||
])
|
||||
.from('between_sessions')
|
||||
.having('events[1]', 'IN', topEntryEvents),
|
||||
)
|
||||
.select<{
|
||||
source: string;
|
||||
target: string;
|
||||
step: number;
|
||||
value: number;
|
||||
}>([
|
||||
'pair.1 as source',
|
||||
'pair.2 as target',
|
||||
'pair.3 as step',
|
||||
'count() as value',
|
||||
])
|
||||
.from(
|
||||
clix.exp(
|
||||
'(SELECT arrayJoin(arrayMap(i -> (events[i], events[i + 1], i), range(1, length(events)))) as pair FROM session_paths WHERE length(events) >= 2)',
|
||||
),
|
||||
)
|
||||
.groupBy(['source', 'target', 'step'])
|
||||
.orderBy('step', 'ASC')
|
||||
.orderBy('value', 'DESC');
|
||||
|
||||
const transitions = await transitionsQuery.execute();
|
||||
|
||||
return this.buildSankeyFromTransitions(
|
||||
transitions,
|
||||
topEntries,
|
||||
totalSessions,
|
||||
steps,
|
||||
COLORS,
|
||||
);
|
||||
}
|
||||
|
||||
private async executeSimpleMode(
|
||||
sessionPathsQuery: ReturnType<typeof clix>,
|
||||
steps: number,
|
||||
COLORS: string[],
|
||||
timezone: string,
|
||||
): Promise<{
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
nodeColor: string;
|
||||
percentage?: number;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}>;
|
||||
links: Array<{ source: string; target: string; value: number }>;
|
||||
}> {
|
||||
// Get top entry events
|
||||
const topEntriesQuery = clix(this.client, timezone)
|
||||
.with('session_paths', sessionPathsQuery)
|
||||
.select<{ entry_event: string; count: number }>([
|
||||
'entry_event',
|
||||
'count() as count',
|
||||
])
|
||||
.from('session_paths')
|
||||
.groupBy(['entry_event'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(3);
|
||||
|
||||
const topEntries = await topEntriesQuery.execute();
|
||||
|
||||
if (topEntries.length === 0) {
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
const topEntryEvents = topEntries.map((e) => e.entry_event);
|
||||
const totalSessions = topEntries.reduce((sum, e) => sum + e.count, 0);
|
||||
|
||||
// Get transitions
|
||||
const transitionsQuery = clix(this.client, timezone)
|
||||
.with('session_paths_base', sessionPathsQuery)
|
||||
.with(
|
||||
'session_paths',
|
||||
clix(this.client, timezone)
|
||||
.select(['session_id', 'events'])
|
||||
.from('session_paths_base')
|
||||
.having('events[1]', 'IN', topEntryEvents),
|
||||
)
|
||||
.select<{
|
||||
source: string;
|
||||
target: string;
|
||||
step: number;
|
||||
value: number;
|
||||
}>([
|
||||
'pair.1 as source',
|
||||
'pair.2 as target',
|
||||
'pair.3 as step',
|
||||
'count() as value',
|
||||
])
|
||||
.from(
|
||||
clix.exp(
|
||||
'(SELECT arrayJoin(arrayMap(i -> (events[i], events[i + 1], i), range(1, length(events)))) as pair FROM session_paths WHERE length(events) >= 2)',
|
||||
),
|
||||
)
|
||||
.groupBy(['source', 'target', 'step'])
|
||||
.orderBy('step', 'ASC')
|
||||
.orderBy('value', 'DESC');
|
||||
|
||||
const transitions = await transitionsQuery.execute();
|
||||
|
||||
return this.buildSankeyFromTransitions(
|
||||
transitions,
|
||||
topEntries,
|
||||
totalSessions,
|
||||
steps,
|
||||
COLORS,
|
||||
);
|
||||
}
|
||||
|
||||
async getSankey({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
steps = 5,
|
||||
mode,
|
||||
startEvent,
|
||||
endEvent,
|
||||
exclude = [],
|
||||
include,
|
||||
timezone,
|
||||
}: IGetSankeyInput): Promise<{
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
nodeColor: string;
|
||||
percentage?: number;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}>;
|
||||
links: Array<{ source: string; target: string; value: number }>;
|
||||
}> {
|
||||
const COLORS = chartColors.map((color) => color.main);
|
||||
|
||||
// 1. Build event name filter
|
||||
const eventNameFilter = this.buildEventNameFilter(
|
||||
include,
|
||||
exclude,
|
||||
startEvent?.name,
|
||||
endEvent?.name,
|
||||
);
|
||||
|
||||
// 2. Build ordered events query
|
||||
// For screen_view events, use the path instead of the event name for more meaningful flow visualization
|
||||
const orderedEventsQuery = clix(this.client, timezone)
|
||||
.select<{
|
||||
session_id: string;
|
||||
event_name: string;
|
||||
created_at: string;
|
||||
}>([
|
||||
'session_id',
|
||||
// "if(name = 'screen_view', path, name) as event_name",
|
||||
'name as event_name',
|
||||
'created_at',
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.orderBy('session_id', 'ASC')
|
||||
.orderBy('created_at', 'ASC');
|
||||
|
||||
if (eventNameFilter) {
|
||||
orderedEventsQuery.rawWhere(eventNameFilter);
|
||||
}
|
||||
|
||||
// 3. Build session event CTEs
|
||||
const startEventCTE = startEvent
|
||||
? this.buildSessionEventCTE(
|
||||
startEvent,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
)
|
||||
: null;
|
||||
const endEventCTE =
|
||||
mode === 'between' && endEvent
|
||||
? this.buildSessionEventCTE(
|
||||
endEvent,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
)
|
||||
: null;
|
||||
|
||||
// 4. Build deduped events CTE
|
||||
const eventsDedupedCTE = clix(this.client, timezone)
|
||||
.with('ordered_events', orderedEventsQuery)
|
||||
.select<{
|
||||
session_id: string;
|
||||
events_deduped: string[];
|
||||
}>([
|
||||
'session_id',
|
||||
`arrayFilter(
|
||||
(x, i) -> i = 1 OR x != events_raw[i - 1],
|
||||
groupArray(event_name) as events_raw,
|
||||
arrayEnumerate(events_raw)
|
||||
) as events_deduped`,
|
||||
])
|
||||
.from('ordered_events')
|
||||
.groupBy(['session_id']);
|
||||
|
||||
// 5. Get mode-specific config
|
||||
const { sessionFilter, eventsSliceExpr } = this.getModeConfig(
|
||||
mode,
|
||||
startEvent,
|
||||
endEvent,
|
||||
startEventCTE !== null,
|
||||
endEventCTE !== null,
|
||||
steps,
|
||||
);
|
||||
|
||||
// 6. Build truncate expression (for 'after' mode)
|
||||
const truncateAtRepeatExpr = `if(
|
||||
arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(events_sliced)) = 0,
|
||||
events_sliced,
|
||||
arraySlice(
|
||||
events_sliced,
|
||||
1,
|
||||
arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(events_sliced)) - 1
|
||||
)
|
||||
)`;
|
||||
const eventsExpr =
|
||||
mode === 'before' ? 'events_sliced' : truncateAtRepeatExpr;
|
||||
|
||||
// 7. Build session paths query with conditional CTEs
|
||||
const eventCTEs: Array<{ name: string; query: ReturnType<typeof clix> }> =
|
||||
[];
|
||||
if (startEventCTE) {
|
||||
eventCTEs.push({ name: 'start_event_sessions', query: startEventCTE });
|
||||
}
|
||||
if (endEventCTE) {
|
||||
eventCTEs.push({ name: 'end_event_sessions', query: endEventCTE });
|
||||
}
|
||||
|
||||
const sessionPathsQuery = eventCTEs
|
||||
.reduce(
|
||||
(builder, cte) => builder.with(cte.name, cte.query),
|
||||
clix(this.client, timezone),
|
||||
)
|
||||
.with('events_deduped_cte', eventsDedupedCTE)
|
||||
.with(
|
||||
'events_sliced_cte',
|
||||
clix(this.client, timezone)
|
||||
.select<{
|
||||
session_id: string;
|
||||
events_sliced: string[];
|
||||
}>(['session_id', `${eventsSliceExpr} as events_sliced`])
|
||||
.from('events_deduped_cte')
|
||||
.rawHaving(sessionFilter || '1 = 1'),
|
||||
)
|
||||
.select<{
|
||||
session_id: string;
|
||||
entry_event: string;
|
||||
events: string[];
|
||||
}>(['session_id', `${eventsExpr} as events`, 'events[1] as entry_event'])
|
||||
.from('events_sliced_cte')
|
||||
.having('length(events)', '>=', 2);
|
||||
|
||||
// 8. Execute mode-specific logic
|
||||
if (mode === 'between' && startEvent && endEvent) {
|
||||
return this.executeBetweenMode(
|
||||
sessionPathsQuery,
|
||||
startEvent,
|
||||
endEvent,
|
||||
steps,
|
||||
COLORS,
|
||||
timezone,
|
||||
);
|
||||
}
|
||||
|
||||
return this.executeSimpleMode(sessionPathsQuery, steps, COLORS, timezone);
|
||||
}
|
||||
|
||||
private buildSankeyFromTransitions(
|
||||
transitions: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
step: number;
|
||||
value: number;
|
||||
}>,
|
||||
topEntries: Array<{ entry_event: string; count: number }>,
|
||||
totalSessions: number,
|
||||
steps: number,
|
||||
COLORS: string[],
|
||||
) {
|
||||
if (transitions.length === 0) {
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
const TOP_DESTINATIONS_PER_NODE = 3;
|
||||
|
||||
// Build the sankey progressively step by step
|
||||
const nodes = new Map<
|
||||
string,
|
||||
{ event: string; value: number; step: number; color: string }
|
||||
>();
|
||||
const links: Array<{ source: string; target: string; value: number }> = [];
|
||||
|
||||
// Helper to create unique node ID
|
||||
const getNodeId = (event: string, step: number) => `${event}::step${step}`;
|
||||
|
||||
// Group transitions by step
|
||||
const transitionsByStep = new Map<number, typeof transitions>();
|
||||
for (const t of transitions) {
|
||||
if (!transitionsByStep.has(t.step)) {
|
||||
transitionsByStep.set(t.step, []);
|
||||
}
|
||||
transitionsByStep.get(t.step)!.push(t);
|
||||
}
|
||||
|
||||
// Initialize with entry events (step 1)
|
||||
const activeNodes = new Map<string, string>(); // event -> nodeId
|
||||
topEntries.forEach((entry, idx) => {
|
||||
const nodeId = getNodeId(entry.entry_event, 1);
|
||||
nodes.set(nodeId, {
|
||||
event: entry.entry_event,
|
||||
value: entry.count,
|
||||
step: 1,
|
||||
color: COLORS[idx % COLORS.length]!,
|
||||
});
|
||||
activeNodes.set(entry.entry_event, nodeId);
|
||||
});
|
||||
|
||||
// Process each step: from active nodes, find top destinations
|
||||
for (let step = 1; step < steps; step++) {
|
||||
const stepTransitions = transitionsByStep.get(step) || [];
|
||||
const nextActiveNodes = new Map<string, string>();
|
||||
|
||||
// For each currently active node, find its top destinations
|
||||
for (const [sourceEvent, sourceNodeId] of activeNodes) {
|
||||
// Get transitions FROM this source event
|
||||
const fromSource = stepTransitions
|
||||
.filter((t) => t.source === sourceEvent)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, TOP_DESTINATIONS_PER_NODE);
|
||||
|
||||
for (const t of fromSource) {
|
||||
// Skip self-loops
|
||||
if (t.source === t.target) continue;
|
||||
|
||||
const targetNodeId = getNodeId(t.target, step + 1);
|
||||
|
||||
// Add link using unique node IDs
|
||||
links.push({
|
||||
source: sourceNodeId,
|
||||
target: targetNodeId,
|
||||
value: t.value,
|
||||
});
|
||||
|
||||
// Add/update target node
|
||||
const existing = nodes.get(targetNodeId);
|
||||
if (existing) {
|
||||
existing.value += t.value;
|
||||
} else {
|
||||
// Inherit color from source or assign new
|
||||
const sourceData = nodes.get(sourceNodeId);
|
||||
nodes.set(targetNodeId, {
|
||||
event: t.target,
|
||||
value: t.value,
|
||||
step: step + 1,
|
||||
color: sourceData?.color || COLORS[nodes.size % COLORS.length]!,
|
||||
});
|
||||
}
|
||||
|
||||
nextActiveNodes.set(t.target, targetNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update active nodes for next iteration
|
||||
activeNodes.clear();
|
||||
for (const [event, nodeId] of nextActiveNodes) {
|
||||
activeNodes.set(event, nodeId);
|
||||
}
|
||||
|
||||
// Stop if no more nodes to process
|
||||
if (activeNodes.size === 0) break;
|
||||
}
|
||||
|
||||
// Filter links by threshold (0.25% of total sessions)
|
||||
const MIN_LINK_PERCENT = 0.25;
|
||||
const minLinkValue = Math.ceil((totalSessions * MIN_LINK_PERCENT) / 100);
|
||||
const filteredLinks = links.filter((link) => link.value >= minLinkValue);
|
||||
|
||||
// Find all nodes referenced by remaining links
|
||||
const referencedNodeIds = new Set<string>();
|
||||
filteredLinks.forEach((link) => {
|
||||
referencedNodeIds.add(link.source);
|
||||
referencedNodeIds.add(link.target);
|
||||
});
|
||||
|
||||
// Recompute node values from filtered links
|
||||
const nodeValuesFromLinks = new Map<string, number>();
|
||||
filteredLinks.forEach((link) => {
|
||||
const current = nodeValuesFromLinks.get(link.target) || 0;
|
||||
nodeValuesFromLinks.set(link.target, current + link.value);
|
||||
});
|
||||
|
||||
// For entry nodes (step 1), only keep them if they have outgoing links after filtering
|
||||
nodes.forEach((nodeData, nodeId) => {
|
||||
if (nodeData.step === 1) {
|
||||
const hasOutgoing = filteredLinks.some((l) => l.source === nodeId);
|
||||
if (!hasOutgoing) {
|
||||
referencedNodeIds.delete(nodeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Build final nodes array sorted by step then value
|
||||
const finalNodes = Array.from(nodes.entries())
|
||||
.filter(([id]) => referencedNodeIds.has(id))
|
||||
.map(([id, data]) => {
|
||||
const value =
|
||||
data.step === 1
|
||||
? data.value
|
||||
: nodeValuesFromLinks.get(id) || data.value;
|
||||
return {
|
||||
id,
|
||||
label: data.event,
|
||||
nodeColor: data.color,
|
||||
percentage: (value / totalSessions) * 100,
|
||||
value,
|
||||
step: data.step,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.step !== b.step) return a.step - b.step;
|
||||
return b.value - a.value;
|
||||
});
|
||||
|
||||
// Sanity check: Ensure all link endpoints exist in nodes
|
||||
const nodeIds = new Set(finalNodes.map((n) => n.id));
|
||||
const validLinks = filteredLinks.filter(
|
||||
(link) => nodeIds.has(link.source) && nodeIds.has(link.target),
|
||||
);
|
||||
|
||||
// Combine final nodes with the same event name
|
||||
// A final node is one that has no outgoing links
|
||||
const nodesWithOutgoing = new Set(validLinks.map((l) => l.source));
|
||||
const finalNodeIds = new Set(
|
||||
finalNodes.filter((n) => !nodesWithOutgoing.has(n.id)).map((n) => n.id),
|
||||
);
|
||||
|
||||
// Group final nodes by event name
|
||||
const finalNodesByEvent = new Map<string, typeof finalNodes>();
|
||||
finalNodes.forEach((node) => {
|
||||
if (finalNodeIds.has(node.id)) {
|
||||
if (!finalNodesByEvent.has(node.label)) {
|
||||
finalNodesByEvent.set(node.label, []);
|
||||
}
|
||||
finalNodesByEvent.get(node.label)!.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Create merged nodes and remap links
|
||||
const nodeIdRemap = new Map<string, string>(); // old nodeId -> new merged nodeId
|
||||
const mergedNodes = new Map<string, (typeof finalNodes)[0]>(); // merged nodeId -> node data
|
||||
|
||||
finalNodesByEvent.forEach((nodesToMerge, eventName) => {
|
||||
if (nodesToMerge.length > 1) {
|
||||
// Merge multiple final nodes with same event name
|
||||
const maxStep = Math.max(...nodesToMerge.map((n) => n.step || 0));
|
||||
const totalValue = nodesToMerge.reduce(
|
||||
(sum, n) => sum + (n.value || 0),
|
||||
0,
|
||||
);
|
||||
const mergedNodeId = `${eventName}::final`;
|
||||
const firstNode = nodesToMerge[0]!;
|
||||
|
||||
// Create merged node at the maximum step
|
||||
mergedNodes.set(mergedNodeId, {
|
||||
id: mergedNodeId,
|
||||
label: eventName,
|
||||
nodeColor: firstNode.nodeColor,
|
||||
percentage: (totalValue / totalSessions) * 100,
|
||||
value: totalValue,
|
||||
step: maxStep,
|
||||
});
|
||||
|
||||
// Map all old node IDs to the merged node ID
|
||||
nodesToMerge.forEach((node) => {
|
||||
nodeIdRemap.set(node.id, mergedNodeId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update links to point to merged nodes
|
||||
const remappedLinks = validLinks.map((link) => {
|
||||
const newSource = nodeIdRemap.get(link.source) || link.source;
|
||||
const newTarget = nodeIdRemap.get(link.target) || link.target;
|
||||
return {
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
value: link.value,
|
||||
};
|
||||
});
|
||||
|
||||
// Combine merged nodes with non-final nodes
|
||||
const nonFinalNodes = finalNodes.filter((n) => !finalNodeIds.has(n.id));
|
||||
const finalNodesList = Array.from(mergedNodes.values());
|
||||
|
||||
// Remove old final nodes that were merged
|
||||
const mergedOldNodeIds = new Set(nodeIdRemap.keys());
|
||||
const remainingNodes = nonFinalNodes.filter(
|
||||
(n) => !mergedOldNodeIds.has(n.id),
|
||||
);
|
||||
|
||||
// Combine all nodes and sort
|
||||
const allNodes = [...remainingNodes, ...finalNodesList].sort((a, b) => {
|
||||
if (a.step !== b.step) return a.step! - b.step!;
|
||||
return b.value! - a.value!;
|
||||
});
|
||||
|
||||
// Aggregate links that now point to the same merged target
|
||||
const linkMap = new Map<string, number>(); // "source->target" -> value
|
||||
remappedLinks.forEach((link) => {
|
||||
const key = `${link.source}->${link.target}`;
|
||||
linkMap.set(key, (linkMap.get(key) || 0) + link.value);
|
||||
});
|
||||
|
||||
const aggregatedLinks = Array.from(linkMap.entries())
|
||||
.map(([key, value]) => {
|
||||
const parts = key.split('->');
|
||||
if (parts.length !== 2) return null;
|
||||
return { source: parts[0]!, target: parts[1]!, value };
|
||||
})
|
||||
.filter(
|
||||
(link): link is { source: string; target: string; value: number } =>
|
||||
link !== null,
|
||||
);
|
||||
|
||||
// Final sanity check: Ensure all link endpoints exist in nodes
|
||||
const finalNodeIdsSet = new Set(allNodes.map((n) => n.id));
|
||||
const finalValidLinks: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
value: number;
|
||||
}> = aggregatedLinks.filter(
|
||||
(link) =>
|
||||
finalNodeIdsSet.has(link.source) && finalNodeIdsSet.has(link.target),
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: allNodes,
|
||||
links: finalValidLinks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const sankeyService = new SankeyService(ch);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { db } from '../prisma-client';
|
||||
import { getProjectAccess } from './access.service';
|
||||
|
||||
export function getShareOverviewById(id: string) {
|
||||
return db.shareOverview.findFirst({
|
||||
@@ -18,3 +19,197 @@ export function getShareByProjectId(projectId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboard sharing functions
|
||||
export function getShareDashboardById(id: string) {
|
||||
return db.shareDashboard.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
dashboard: {
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getShareDashboardByDashboardId(dashboardId: string) {
|
||||
return db.shareDashboard.findUnique({
|
||||
where: {
|
||||
dashboardId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Report sharing functions
|
||||
export function getShareReportById(id: string) {
|
||||
return db.shareReport.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
report: {
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getShareReportByReportId(reportId: string) {
|
||||
return db.shareReport.findUnique({
|
||||
where: {
|
||||
reportId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Validation for secure endpoints
|
||||
export async function validateReportAccess(
|
||||
reportId: string,
|
||||
shareId: string,
|
||||
shareType: 'dashboard' | 'report',
|
||||
) {
|
||||
if (shareType === 'dashboard') {
|
||||
const share = await db.shareDashboard.findUnique({
|
||||
where: { id: shareId },
|
||||
include: {
|
||||
dashboard: {
|
||||
include: {
|
||||
reports: {
|
||||
where: { id: reportId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!share || !share.public) {
|
||||
throw new Error('Share not found or not public');
|
||||
}
|
||||
|
||||
if (!share.dashboard.reports.some((r) => r.id === reportId)) {
|
||||
throw new Error('Report does not belong to this dashboard');
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
const share = await db.shareReport.findUnique({
|
||||
where: { id: shareId },
|
||||
include: {
|
||||
report: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share || !share.public) {
|
||||
throw new Error('Share not found or not public');
|
||||
}
|
||||
|
||||
if (share.reportId !== reportId) {
|
||||
throw new Error('Report ID mismatch');
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
// Unified validation for share access
|
||||
export async function validateShareAccess(
|
||||
shareId: string,
|
||||
reportId: string,
|
||||
ctx: {
|
||||
cookies: Record<string, string | undefined>;
|
||||
session?: { userId?: string | null };
|
||||
},
|
||||
): Promise<{ projectId: string; isValid: boolean }> {
|
||||
// Check ShareDashboard first
|
||||
const dashboardShare = await db.shareDashboard.findUnique({
|
||||
where: { id: shareId },
|
||||
include: {
|
||||
dashboard: {
|
||||
include: {
|
||||
reports: {
|
||||
where: { id: reportId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
dashboardShare?.dashboard?.reports &&
|
||||
dashboardShare.dashboard.reports.length > 0
|
||||
) {
|
||||
if (!dashboardShare.public) {
|
||||
throw new Error('Share not found or not public');
|
||||
}
|
||||
|
||||
const projectId = dashboardShare.projectId;
|
||||
|
||||
// If no password is set, share is public and accessible
|
||||
if (!dashboardShare.password) {
|
||||
return {
|
||||
projectId,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
// If password is set, require cookie OR member access
|
||||
const hasCookie = !!ctx.cookies[`shared-dashboard-${shareId}`];
|
||||
const hasMemberAccess =
|
||||
ctx.session?.userId &&
|
||||
(await getProjectAccess({
|
||||
userId: ctx.session.userId,
|
||||
projectId,
|
||||
}));
|
||||
|
||||
return {
|
||||
projectId,
|
||||
isValid: hasCookie || !!hasMemberAccess,
|
||||
};
|
||||
}
|
||||
|
||||
// Check ShareReport
|
||||
const reportShare = await db.shareReport.findUnique({
|
||||
where: { id: shareId, reportId },
|
||||
include: {
|
||||
report: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (reportShare) {
|
||||
if (!reportShare.public) {
|
||||
throw new Error('Share not found or not public');
|
||||
}
|
||||
|
||||
const projectId = reportShare.projectId;
|
||||
|
||||
// If no password is set, share is public and accessible
|
||||
if (!reportShare.password) {
|
||||
return {
|
||||
projectId,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
// If password is set, require cookie OR member access
|
||||
const hasCookie = !!ctx.cookies[`shared-report-${shareId}`];
|
||||
const hasMemberAccess =
|
||||
ctx.session?.userId &&
|
||||
(await getProjectAccess({
|
||||
userId: ctx.session.userId,
|
||||
projectId,
|
||||
}));
|
||||
|
||||
return {
|
||||
projectId,
|
||||
isValid: hasCookie || !!hasMemberAccess,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Share not found');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -352,8 +352,23 @@ export const authRouter = createTRPCRouter({
|
||||
)
|
||||
.input(zSignInShare)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { password, shareId } = input;
|
||||
const share = await getShareOverviewById(input.shareId);
|
||||
const { password, shareId, shareType = 'overview' } = input;
|
||||
|
||||
let share: { password: string | null; public: boolean } | null = null;
|
||||
let cookieName = '';
|
||||
|
||||
if (shareType === 'overview') {
|
||||
share = await getShareOverviewById(shareId);
|
||||
cookieName = `shared-overview-${shareId}`;
|
||||
} else if (shareType === 'dashboard') {
|
||||
const { getShareDashboardById } = await import('@openpanel/db');
|
||||
share = await getShareDashboardById(shareId);
|
||||
cookieName = `shared-dashboard-${shareId}`;
|
||||
} else if (shareType === 'report') {
|
||||
const { getShareReportById } = await import('@openpanel/db');
|
||||
share = await getShareReportById(shareId);
|
||||
cookieName = `shared-report-${shareId}`;
|
||||
}
|
||||
|
||||
if (!share) {
|
||||
throw TRPCNotFoundError('Share not found');
|
||||
@@ -373,7 +388,7 @@ export const authRouter = createTRPCRouter({
|
||||
throw TRPCAccessError('Incorrect password');
|
||||
}
|
||||
|
||||
ctx.setCookie(`shared-overview-${shareId}`, '1', {
|
||||
ctx.setCookie(cookieName, '1', {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
...COOKIE_OPTIONS,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
clix,
|
||||
conversionService,
|
||||
createSqlBuilder,
|
||||
db,
|
||||
formatClickhouseDate,
|
||||
funnelService,
|
||||
getChartPrevStartEndDate,
|
||||
@@ -19,15 +18,16 @@ import {
|
||||
getEventFiltersWhereClause,
|
||||
getEventMetasCached,
|
||||
getProfilesCached,
|
||||
getReportById,
|
||||
getSelectPropertyKey,
|
||||
getSettingsForProject,
|
||||
onlyReportEvents,
|
||||
sankeyService,
|
||||
validateShareAccess,
|
||||
} from '@openpanel/db';
|
||||
import {
|
||||
type IChartEvent,
|
||||
zChartEvent,
|
||||
zChartEventFilter,
|
||||
zChartInput,
|
||||
zReportInput,
|
||||
zChartSeries,
|
||||
zCriteria,
|
||||
zRange,
|
||||
@@ -333,124 +333,342 @@ export const chartRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
funnel: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||
funnel: publicProcedure
|
||||
.input(
|
||||
zReportInput.and(
|
||||
z.object({
|
||||
shareId: z.string().optional(),
|
||||
reportId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
let chartInput = input;
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
funnelService.getFunnel({ ...input, ...currentPeriod, timezone }),
|
||||
input.previous
|
||||
? funnelService.getFunnel({ ...input, ...previousPeriod, timezone })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
if (input.shareId) {
|
||||
// Require reportId when shareId provided
|
||||
if (!input.reportId) {
|
||||
throw new Error('reportId required with shareId');
|
||||
}
|
||||
|
||||
return {
|
||||
current,
|
||||
previous,
|
||||
};
|
||||
}),
|
||||
// Validate share access
|
||||
const shareValidation = await validateShareAccess(
|
||||
input.shareId,
|
||||
input.reportId,
|
||||
{
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!shareValidation.isValid) {
|
||||
throw TRPCAccessError('You do not have access to this share');
|
||||
}
|
||||
|
||||
conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||
// Fetch report and merge date overrides
|
||||
const report = await getReportById(input.reportId);
|
||||
if (!report) {
|
||||
throw TRPCAccessError('Report not found');
|
||||
}
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
conversionService.getConversion({ ...input, ...currentPeriod, timezone }),
|
||||
input.previous
|
||||
? conversionService.getConversion({
|
||||
...input,
|
||||
...previousPeriod,
|
||||
timezone,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
chartInput = {
|
||||
...report,
|
||||
// Only allow date overrides
|
||||
range: input.range ?? report.range,
|
||||
startDate: input.startDate ?? report.startDate,
|
||||
endDate: input.endDate ?? report.endDate,
|
||||
interval: input.interval ?? report.interval,
|
||||
};
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
current: current.map((serie, sIndex) => ({
|
||||
...serie,
|
||||
data: serie.data.map((d, dIndex) => ({
|
||||
...d,
|
||||
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
|
||||
const { timezone } = await getSettingsForProject(chartInput.projectId);
|
||||
const currentPeriod = getChartStartEndDate(chartInput, timezone);
|
||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
funnelService.getFunnel({ ...chartInput, ...currentPeriod, timezone }),
|
||||
chartInput.previous
|
||||
? funnelService.getFunnel({
|
||||
...chartInput,
|
||||
...previousPeriod,
|
||||
timezone,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
current,
|
||||
previous,
|
||||
};
|
||||
}),
|
||||
|
||||
conversion: publicProcedure
|
||||
.input(
|
||||
zReportInput.and(
|
||||
z.object({
|
||||
shareId: z.string().optional(),
|
||||
reportId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
let chartInput = input;
|
||||
|
||||
if (input.shareId) {
|
||||
// Require reportId when shareId provided
|
||||
if (!input.reportId) {
|
||||
throw new Error('reportId required with shareId');
|
||||
}
|
||||
|
||||
// Validate share access
|
||||
const shareValidation = await validateShareAccess(
|
||||
input.shareId,
|
||||
input.reportId,
|
||||
{
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!shareValidation.isValid) {
|
||||
throw TRPCAccessError('You do not have access to this share');
|
||||
}
|
||||
|
||||
// Fetch report and merge date overrides
|
||||
const report = await getReportById(input.reportId);
|
||||
if (!report) {
|
||||
throw TRPCAccessError('Report not found');
|
||||
}
|
||||
|
||||
chartInput = {
|
||||
...report,
|
||||
// Only allow date overrides
|
||||
range: input.range ?? report.range,
|
||||
startDate: input.startDate ?? report.startDate,
|
||||
endDate: input.endDate ?? report.endDate,
|
||||
interval: input.interval ?? report.interval,
|
||||
};
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
const { timezone } = await getSettingsForProject(chartInput.projectId);
|
||||
const currentPeriod = getChartStartEndDate(chartInput, timezone);
|
||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||
|
||||
const interval = chartInput.interval;
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
conversionService.getConversion({
|
||||
...chartInput,
|
||||
...currentPeriod,
|
||||
interval,
|
||||
timezone,
|
||||
}),
|
||||
chartInput.previous
|
||||
? conversionService.getConversion({
|
||||
...chartInput,
|
||||
...previousPeriod,
|
||||
interval,
|
||||
timezone,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
current: current.map((serie, sIndex) => ({
|
||||
...serie,
|
||||
data: serie.data.map((d, dIndex) => ({
|
||||
...d,
|
||||
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
previous,
|
||||
};
|
||||
previous,
|
||||
};
|
||||
}),
|
||||
|
||||
sankey: protectedProcedure.input(zReportInput).query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
||||
|
||||
// Extract sankey options
|
||||
const options = input.options;
|
||||
|
||||
if (!options || options.type !== 'sankey') {
|
||||
throw new Error('Sankey options are required');
|
||||
}
|
||||
|
||||
// Extract start/end events from series based on mode
|
||||
const eventSeries = onlyReportEvents(input.series);
|
||||
|
||||
if (!eventSeries[0]) {
|
||||
throw new Error('Start and end events are required');
|
||||
}
|
||||
|
||||
return sankeyService.getSankey({
|
||||
projectId: input.projectId,
|
||||
startDate: currentPeriod.startDate,
|
||||
endDate: currentPeriod.endDate,
|
||||
steps: options.steps,
|
||||
mode: options.mode,
|
||||
startEvent: eventSeries[0],
|
||||
endEvent: eventSeries[1],
|
||||
exclude: options.exclude || [],
|
||||
include: options.include,
|
||||
timezone,
|
||||
});
|
||||
}),
|
||||
|
||||
chart: publicProcedure
|
||||
// .use(cacher)
|
||||
.input(zChartInput)
|
||||
.input(
|
||||
zReportInput.and(
|
||||
z.object({
|
||||
shareId: z.string().optional(),
|
||||
reportId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (ctx.session.userId) {
|
||||
let chartInput = input;
|
||||
|
||||
if (input.shareId) {
|
||||
// Require reportId when shareId provided
|
||||
if (!input.reportId) {
|
||||
throw new Error('reportId required with shareId');
|
||||
}
|
||||
|
||||
// Validate share access
|
||||
const shareValidation = await validateShareAccess(
|
||||
input.shareId,
|
||||
input.reportId,
|
||||
ctx,
|
||||
);
|
||||
|
||||
if (!shareValidation.isValid) {
|
||||
throw TRPCAccessError('You do not have access to this share');
|
||||
}
|
||||
|
||||
// Fetch report and merge date overrides
|
||||
const report = await getReportById(input.reportId);
|
||||
if (!report) {
|
||||
throw TRPCAccessError('Report not found');
|
||||
}
|
||||
|
||||
chartInput = {
|
||||
...report,
|
||||
// Only allow date overrides
|
||||
range: input.range ?? report.range,
|
||||
startDate: input.startDate ?? report.startDate,
|
||||
endDate: input.endDate ?? report.endDate,
|
||||
interval: input.interval ?? report.interval,
|
||||
};
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
const share = await db.shareOverview.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const share = await db.shareOverview.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
// Use new chart engine
|
||||
return ChartEngine.execute(input);
|
||||
return ChartEngine.execute(chartInput);
|
||||
}),
|
||||
|
||||
aggregate: publicProcedure
|
||||
.input(zChartInput)
|
||||
.input(
|
||||
zReportInput.and(
|
||||
z.object({
|
||||
shareId: z.string().optional(),
|
||||
reportId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (ctx.session.userId) {
|
||||
let chartInput = input;
|
||||
|
||||
if (input.shareId) {
|
||||
// Require reportId when shareId provided
|
||||
if (!input.reportId) {
|
||||
throw new Error('reportId required with shareId');
|
||||
}
|
||||
|
||||
// Validate share access
|
||||
const shareValidation = await validateShareAccess(
|
||||
input.shareId,
|
||||
input.reportId,
|
||||
{
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!shareValidation.isValid) {
|
||||
throw TRPCAccessError('You do not have access to this share');
|
||||
}
|
||||
|
||||
// Fetch report and merge date overrides
|
||||
const report = await getReportById(input.reportId);
|
||||
if (!report) {
|
||||
throw TRPCAccessError('Report not found');
|
||||
}
|
||||
|
||||
chartInput = {
|
||||
...report,
|
||||
// Only allow date overrides
|
||||
range: input.range ?? report.range,
|
||||
startDate: input.startDate ?? report.startDate,
|
||||
endDate: input.endDate ?? report.endDate,
|
||||
interval: input.interval ?? report.interval,
|
||||
};
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
const share = await db.shareOverview.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const share = await db.shareOverview.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
// Use aggregate chart engine (optimized for bar/pie charts)
|
||||
return AggregateChartEngine.execute(input);
|
||||
return AggregateChartEngine.execute(chartInput);
|
||||
}),
|
||||
|
||||
cohort: protectedProcedure
|
||||
cohort: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -461,26 +679,110 @@ export const chartRouter = createTRPCRouter({
|
||||
endDate: z.string().nullish(),
|
||||
interval: zTimeInterval.default('day'),
|
||||
range: zRange,
|
||||
shareId: z.string().optional(),
|
||||
reportId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { projectId, firstEvent, secondEvent } = input;
|
||||
const dates = getChartStartEndDate(input, timezone);
|
||||
.query(async ({ input, ctx }) => {
|
||||
let projectId = input.projectId;
|
||||
let firstEvent = input.firstEvent;
|
||||
let secondEvent = input.secondEvent;
|
||||
let criteria = input.criteria;
|
||||
let dateRange = input.range;
|
||||
let startDate = input.startDate;
|
||||
let endDate = input.endDate;
|
||||
let interval = input.interval;
|
||||
|
||||
if (input.shareId) {
|
||||
// Require reportId when shareId provided
|
||||
if (!input.reportId) {
|
||||
throw new Error('reportId required with shareId');
|
||||
}
|
||||
|
||||
// Validate share access
|
||||
const shareValidation = await validateShareAccess(
|
||||
input.shareId,
|
||||
input.reportId,
|
||||
{
|
||||
cookies: ctx.cookies,
|
||||
session: ctx.session?.userId
|
||||
? { userId: ctx.session.userId }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!shareValidation.isValid) {
|
||||
throw TRPCAccessError('You do not have access to this share');
|
||||
}
|
||||
|
||||
// Fetch report and extract events
|
||||
const report = await getReportById(input.reportId);
|
||||
if (!report) {
|
||||
throw TRPCAccessError('Report not found');
|
||||
}
|
||||
|
||||
projectId = report.projectId;
|
||||
const retentionOptions = report.options?.type === 'retention' ? report.options : undefined;
|
||||
criteria = retentionOptions?.criteria ?? criteria;
|
||||
dateRange = input.range ?? report.range;
|
||||
startDate = input.startDate ?? report.startDate;
|
||||
endDate = input.endDate ?? report.endDate;
|
||||
interval = input.interval ?? report.interval;
|
||||
|
||||
// Extract events from report series
|
||||
const eventSeries = onlyReportEvents(report.series);
|
||||
const extractedFirstEvent = (
|
||||
eventSeries[0]?.filters?.[0]?.value ?? []
|
||||
).map(String);
|
||||
const extractedSecondEvent = (
|
||||
eventSeries[1]?.filters?.[0]?.value ?? []
|
||||
).map(String);
|
||||
|
||||
if (
|
||||
extractedFirstEvent.length === 0 ||
|
||||
extractedSecondEvent.length === 0
|
||||
) {
|
||||
throw new Error('Report must have at least 2 event series');
|
||||
}
|
||||
|
||||
firstEvent = extractedFirstEvent;
|
||||
secondEvent = extractedSecondEvent;
|
||||
} else {
|
||||
// Regular member access check
|
||||
if (!ctx.session?.userId) {
|
||||
throw TRPCAccessError('Authentication required');
|
||||
}
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
}
|
||||
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const dates = getChartStartEndDate(
|
||||
{
|
||||
range: dateRange,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
timezone,
|
||||
);
|
||||
const diffInterval = {
|
||||
minute: () => differenceInDays(dates.endDate, dates.startDate),
|
||||
hour: () => differenceInDays(dates.endDate, dates.startDate),
|
||||
day: () => differenceInDays(dates.endDate, dates.startDate),
|
||||
week: () => differenceInWeeks(dates.endDate, dates.startDate),
|
||||
month: () => differenceInMonths(dates.endDate, dates.startDate),
|
||||
}[input.interval]();
|
||||
}[interval]();
|
||||
const sqlInterval = {
|
||||
minute: 'DAY',
|
||||
hour: 'DAY',
|
||||
day: 'DAY',
|
||||
week: 'WEEK',
|
||||
month: 'MONTH',
|
||||
}[input.interval];
|
||||
}[interval];
|
||||
|
||||
const sqlToStartOf = {
|
||||
minute: 'toDate',
|
||||
@@ -488,9 +790,9 @@ export const chartRouter = createTRPCRouter({
|
||||
day: 'toDate',
|
||||
week: 'toStartOfWeek',
|
||||
month: 'toStartOfMonth',
|
||||
}[input.interval];
|
||||
}[interval];
|
||||
|
||||
const countCriteria = input.criteria === 'on_or_after' ? '>=' : '=';
|
||||
const countCriteria = criteria === 'on_or_after' ? '>=' : '=';
|
||||
|
||||
const usersSelect = range(0, diffInterval + 1)
|
||||
.map(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db, getReportById, getReportsByDashboardId } from '@openpanel/db';
|
||||
import { zReportInput } from '@openpanel/validation';
|
||||
import { zReport } from '@openpanel/validation';
|
||||
|
||||
import { getProjectAccess } from '../access';
|
||||
import { TRPCAccessError } from '../errors';
|
||||
@@ -21,7 +21,7 @@ export const reportRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
report: zReportInput.omit({ projectId: true }),
|
||||
report: zReport.omit({ projectId: true }),
|
||||
dashboardId: z.string(),
|
||||
}),
|
||||
)
|
||||
@@ -55,10 +55,8 @@ export const reportRouter = createTRPCRouter({
|
||||
formula: report.formula,
|
||||
previous: report.previous ?? false,
|
||||
unit: report.unit,
|
||||
criteria: report.criteria,
|
||||
metric: report.metric === 'count' ? 'sum' : report.metric,
|
||||
funnelGroup: report.funnelGroup,
|
||||
funnelWindow: report.funnelWindow,
|
||||
options: report.options,
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -66,7 +64,7 @@ export const reportRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
reportId: z.string(),
|
||||
report: zReportInput.omit({ projectId: true }),
|
||||
report: zReport.omit({ projectId: true }),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input: { report, reportId }, ctx }) => {
|
||||
@@ -100,10 +98,8 @@ export const reportRouter = createTRPCRouter({
|
||||
formula: report.formula,
|
||||
previous: report.previous ?? false,
|
||||
unit: report.unit,
|
||||
criteria: report.criteria,
|
||||
metric: report.metric === 'count' ? 'sum' : report.metric,
|
||||
funnelGroup: report.funnelGroup,
|
||||
funnelWindow: report.funnelWindow,
|
||||
options: report.options,
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -171,10 +167,8 @@ export const reportRouter = createTRPCRouter({
|
||||
formula: report.formula,
|
||||
previous: report.previous,
|
||||
unit: report.unit,
|
||||
criteria: report.criteria,
|
||||
metric: report.metric,
|
||||
funnelGroup: report.funnelGroup,
|
||||
funnelWindow: report.funnelWindow,
|
||||
options: report.options,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import ShortUniqueId from 'short-unique-id';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
import { zShareOverview } from '@openpanel/validation';
|
||||
import {
|
||||
db,
|
||||
getReportById,
|
||||
getReportsByDashboardId,
|
||||
getShareDashboardById,
|
||||
getShareReportById,
|
||||
transformReport,
|
||||
} from '@openpanel/db';
|
||||
import {
|
||||
zShareDashboard,
|
||||
zShareOverview,
|
||||
zShareReport,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import { hashPassword } from '@openpanel/auth';
|
||||
import { z } from 'zod';
|
||||
import { TRPCNotFoundError } from '../errors';
|
||||
import { getProjectAccess } from '../access';
|
||||
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
|
||||
const uid = new ShortUniqueId({ length: 6 });
|
||||
@@ -85,4 +97,203 @@ export const shareRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// Dashboard sharing
|
||||
dashboard: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
dashboardId: z.string(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
shareId: z.string(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const share = await db.shareDashboard.findUnique({
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where:
|
||||
'dashboardId' in input
|
||||
? {
|
||||
dashboardId: input.dashboardId,
|
||||
}
|
||||
: {
|
||||
id: input.shareId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
if ('shareId' in input) {
|
||||
throw TRPCNotFoundError('Dashboard share not found');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...share,
|
||||
hasAccess: !!ctx.cookies[`shared-dashboard-${share?.id}`],
|
||||
};
|
||||
}),
|
||||
|
||||
createDashboard: protectedProcedure
|
||||
.input(zShareDashboard)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
|
||||
const passwordHash = input.password
|
||||
? await hashPassword(input.password)
|
||||
: null;
|
||||
|
||||
return db.shareDashboard.upsert({
|
||||
where: {
|
||||
dashboardId: input.dashboardId,
|
||||
},
|
||||
create: {
|
||||
id: uid.rnd(),
|
||||
organizationId: input.organizationId,
|
||||
projectId: input.projectId,
|
||||
dashboardId: input.dashboardId,
|
||||
public: input.public,
|
||||
password: passwordHash,
|
||||
},
|
||||
update: {
|
||||
public: input.public,
|
||||
password: passwordHash,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
dashboardReports: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
shareId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const share = await getShareDashboardById(input.shareId);
|
||||
|
||||
if (!share || !share.public) {
|
||||
throw TRPCNotFoundError('Dashboard share not found');
|
||||
}
|
||||
|
||||
// Check password access
|
||||
const hasAccess = !!ctx.cookies[`shared-dashboard-${share.id}`];
|
||||
if (share.password && !hasAccess) {
|
||||
throw TRPCAccessError('Password required');
|
||||
}
|
||||
|
||||
return getReportsByDashboardId(share.dashboardId);
|
||||
}),
|
||||
|
||||
// Report sharing
|
||||
report: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
reportId: z.string(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
shareId: z.string(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const share = await db.shareReport.findUnique({
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
report: true,
|
||||
},
|
||||
where:
|
||||
'reportId' in input
|
||||
? {
|
||||
reportId: input.reportId,
|
||||
}
|
||||
: {
|
||||
id: input.shareId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
if ('shareId' in input) {
|
||||
throw TRPCNotFoundError('Report share not found');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...share,
|
||||
hasAccess: !!ctx.cookies[`shared-report-${share?.id}`],
|
||||
report: transformReport(share.report),
|
||||
};
|
||||
}),
|
||||
|
||||
createReport: protectedProcedure
|
||||
.input(zShareReport)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.userId,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
|
||||
const passwordHash = input.password
|
||||
? await hashPassword(input.password)
|
||||
: null;
|
||||
|
||||
return db.shareReport.upsert({
|
||||
where: {
|
||||
reportId: input.reportId,
|
||||
},
|
||||
create: {
|
||||
id: uid.rnd(),
|
||||
organizationId: input.organizationId,
|
||||
projectId: input.projectId,
|
||||
reportId: input.reportId,
|
||||
public: input.public,
|
||||
password: passwordHash,
|
||||
},
|
||||
update: {
|
||||
public: input.public,
|
||||
password: passwordHash,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -86,41 +86,12 @@ export const zChartBreakdown = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
// Support both old format (array of events without type) and new format (array of event/formula items)
|
||||
// Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event'
|
||||
export const zChartSeries = z.preprocess((val) => {
|
||||
if (!val) return val;
|
||||
let processedVal = val;
|
||||
|
||||
// If the input is an object with numeric keys, convert it to an array
|
||||
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
||||
const keys = Object.keys(val).sort(
|
||||
(a, b) => Number.parseInt(a) - Number.parseInt(b),
|
||||
);
|
||||
processedVal = keys.map((key) => (val as any)[key]);
|
||||
}
|
||||
|
||||
if (!Array.isArray(processedVal)) return processedVal;
|
||||
|
||||
return processedVal.map((item: any) => {
|
||||
// If item already has type field, return as-is
|
||||
if (item && typeof item === 'object' && 'type' in item) {
|
||||
return item;
|
||||
}
|
||||
// Otherwise, add type: 'event' for backward compatibility
|
||||
if (item && typeof item === 'object' && 'name' in item) {
|
||||
return { ...item, type: 'event' };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}, z
|
||||
export const zChartSeries = z
|
||||
.array(zChartEventItem)
|
||||
.describe(
|
||||
'Array of series (events or formulas) to be tracked and displayed in the chart',
|
||||
));
|
||||
);
|
||||
|
||||
// Keep zChartEvents as an alias for backward compatibility during migration
|
||||
export const zChartEvents = zChartSeries;
|
||||
export const zChartBreakdowns = z.array(zChartBreakdown);
|
||||
|
||||
export const zChartType = z.enum(objectToZodEnums(chartTypes));
|
||||
@@ -135,7 +106,61 @@ export const zRange = z.enum(objectToZodEnums(timeWindows));
|
||||
|
||||
export const zCriteria = z.enum(['on_or_after', 'on']);
|
||||
|
||||
export const zChartInputBase = z.object({
|
||||
// Report Options - Discriminated union based on chart type
|
||||
export const zFunnelOptions = z.object({
|
||||
type: z.literal('funnel'),
|
||||
funnelGroup: z.string().optional(),
|
||||
funnelWindow: z.number().optional(),
|
||||
});
|
||||
|
||||
export const zRetentionOptions = z.object({
|
||||
type: z.literal('retention'),
|
||||
criteria: zCriteria.optional(),
|
||||
});
|
||||
|
||||
export const zSankeyOptions = z.object({
|
||||
type: z.literal('sankey'),
|
||||
mode: z.enum(['between', 'after', 'before']),
|
||||
steps: z.number().min(2).max(10).default(5),
|
||||
exclude: z.array(z.string()).default([]),
|
||||
include: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const zReportOptions = z.discriminatedUnion('type', [
|
||||
zFunnelOptions,
|
||||
zRetentionOptions,
|
||||
zSankeyOptions,
|
||||
]);
|
||||
|
||||
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'),
|
||||
chartType: zChartType
|
||||
.default('linear')
|
||||
.describe('What type of chart should be displayed'),
|
||||
@@ -153,6 +178,18 @@ export const zChartInputBase = z.object({
|
||||
range: zRange
|
||||
.default('30d')
|
||||
.describe('The time range for which data should be displayed'),
|
||||
startDate: z
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Custom start date for the data range (overrides range if provided)',
|
||||
),
|
||||
endDate: z
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Custom end date for the data range (overrides range if provided)',
|
||||
),
|
||||
previous: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
@@ -166,19 +203,6 @@ export const zChartInputBase = z.object({
|
||||
.describe(
|
||||
'The aggregation method for the metric (e.g., sum, count, average)',
|
||||
),
|
||||
projectId: z.string().describe('The ID of the project this chart belongs to'),
|
||||
startDate: z
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Custom start date for the data range (overrides range if provided)',
|
||||
),
|
||||
endDate: z
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Custom end date for the data range (overrides range if provided)',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
@@ -187,32 +211,14 @@ export const zChartInputBase = z.object({
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Skip how many series should be returned'),
|
||||
criteria: zCriteria
|
||||
options: zReportOptions
|
||||
.optional()
|
||||
.describe('Filtering criteria for retention chart (e.g., on_or_after, on)'),
|
||||
funnelGroup: z
|
||||
.string()
|
||||
.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(
|
||||
'Group identifier for funnel analysis, e.g. "profile_id" or "session_id"',
|
||||
),
|
||||
funnelWindow: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Time window in hours for funnel analysis'),
|
||||
});
|
||||
|
||||
export const zChartInput = z.preprocess((val) => {
|
||||
if (val && typeof val === 'object' && 'events' in val && !('series' in val)) {
|
||||
// Migrate old 'events' field to 'series'
|
||||
return { ...val, series: val.events };
|
||||
}
|
||||
return val;
|
||||
}, zChartInputBase);
|
||||
|
||||
export const zReportInput = zChartInputBase.extend({
|
||||
name: z.string().describe('The user-defined name for the report'),
|
||||
lineType: zLineType.describe('The visual style of the line in the chart'),
|
||||
.describe('The visual style of the line in the chart'),
|
||||
unit: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -221,17 +227,19 @@ export const zReportInput = zChartInputBase.extend({
|
||||
),
|
||||
});
|
||||
|
||||
export const zChartInputAI = zReportInput
|
||||
.omit({
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lineType: true,
|
||||
unit: true,
|
||||
})
|
||||
.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
});
|
||||
// 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'),
|
||||
});
|
||||
|
||||
// Alias for backward compatibility
|
||||
export const zChartInput = zReportInput;
|
||||
|
||||
export const zInviteUser = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -247,6 +255,22 @@ export const zShareOverview = z.object({
|
||||
public: z.boolean(),
|
||||
});
|
||||
|
||||
export const zShareDashboard = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
dashboardId: z.string(),
|
||||
password: z.string().nullable(),
|
||||
public: z.boolean(),
|
||||
});
|
||||
|
||||
export const zShareReport = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
reportId: z.string(),
|
||||
password: z.string().nullable(),
|
||||
public: z.boolean(),
|
||||
});
|
||||
|
||||
export const zCreateReference = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullish(),
|
||||
@@ -486,6 +510,10 @@ export type IRequestResetPassword = z.infer<typeof zRequestResetPassword>;
|
||||
export const zSignInShare = z.object({
|
||||
password: z.string().min(1),
|
||||
shareId: z.string().min(1),
|
||||
shareType: z
|
||||
.enum(['overview', 'dashboard', 'report'])
|
||||
.optional()
|
||||
.default('overview'),
|
||||
});
|
||||
export type ISignInShare = z.infer<typeof zSignInShare>;
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { zChartEvents } from '.';
|
||||
|
||||
const events = [
|
||||
{
|
||||
id: 'sAmT',
|
||||
type: 'event',
|
||||
name: 'session_end',
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
id: '5K2v',
|
||||
type: 'event',
|
||||
name: 'session_start',
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
id: 'lQiQ',
|
||||
type: 'formula',
|
||||
formula: 'A/B',
|
||||
displayName: '',
|
||||
},
|
||||
];
|
||||
|
||||
const res = zChartEvents.safeParse(events);
|
||||
|
||||
console.log(res);
|
||||
@@ -10,26 +10,28 @@ import type {
|
||||
zChartEventItem,
|
||||
zChartEventSegment,
|
||||
zChartFormula,
|
||||
zChartInput,
|
||||
zChartInputAI,
|
||||
zChartSeries,
|
||||
zChartType,
|
||||
zCriteria,
|
||||
zLineType,
|
||||
zMetric,
|
||||
zRange,
|
||||
zReport,
|
||||
zReportInput,
|
||||
zTimeInterval,
|
||||
} from './index';
|
||||
|
||||
export type IChartInput = z.infer<typeof zChartInput>;
|
||||
export type IChartInputAi = z.infer<typeof zChartInputAI>;
|
||||
export type IChartProps = z.infer<typeof zReportInput> & {
|
||||
name: string;
|
||||
lineType: IChartLineType;
|
||||
unit?: string;
|
||||
previousIndicatorInverted?: boolean;
|
||||
};
|
||||
// For saved reports - complete report with required display fields
|
||||
export type IReport = z.infer<typeof zReport>;
|
||||
|
||||
// For API/engine use - flexible input
|
||||
export type IReportInput = z.infer<typeof zReportInput>;
|
||||
|
||||
// With resolved dates (engine internal)
|
||||
export interface IReportInputWithDates extends IReportInput {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
export type IChartEvent = z.infer<typeof zChartEvent>;
|
||||
export type IChartFormula = z.infer<typeof zChartFormula>;
|
||||
export type IChartEventItem = z.infer<typeof zChartEventItem>;
|
||||
@@ -48,16 +50,12 @@ export type IChartType = z.infer<typeof zChartType>;
|
||||
export type IChartMetric = z.infer<typeof zMetric>;
|
||||
export type IChartLineType = z.infer<typeof zLineType>;
|
||||
export type IChartRange = z.infer<typeof zRange>;
|
||||
export interface IChartInputWithDates extends IChartInput {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
export type IGetChartDataInput = {
|
||||
event: IChartEvent;
|
||||
projectId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
} & Omit<IChartInput, 'series' | 'name' | 'startDate' | 'endDate' | 'range'>;
|
||||
} & Omit<IReportInput, 'series' | 'startDate' | 'endDate' | 'range'>;
|
||||
export type ICriteria = z.infer<typeof zCriteria>;
|
||||
|
||||
export type PreviousValue =
|
||||
|
||||
Reference in New Issue
Block a user