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:
Carl-Gerhard Lindesvärd
2026-01-14 09:21:18 +01:00
committed by GitHub
parent 39251c8598
commit ed1c57dbb8
105 changed files with 6633 additions and 1273 deletions

View File

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

View File

@@ -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,
});

View File

@@ -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(

View File

@@ -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,
},
});
}),

View File

@@ -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,
},
});
}),
});

View File

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