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
@@ -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,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user