This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-10 21:55:24 +01:00
parent ba79ac570c
commit 347a01a941
35 changed files with 1544 additions and 1404 deletions

View File

@@ -94,10 +94,11 @@ export function transformReport(
| 'on_or_after'
| 'on'
| undefined,
layout: report.layout ?? undefined,
options: options ?? undefined,
// Depercated, just for frontend backward compatibility (will be removed)
funnelGroup: report.funnelGroup ?? undefined,
funnelWindow: report.funnelWindow ?? undefined,
options: options ?? undefined,
layout: report.layout ?? undefined,
};
}

View File

@@ -1,4 +1,5 @@
import { db } from '../prisma-client';
import { getProjectAccess } from './access.service';
export function getShareOverviewById(id: string) {
return db.shareOverview.findFirst({
@@ -96,22 +97,119 @@ export async function validateReportAccess(
}
return share;
} else {
const share = await db.shareReport.findUnique({
where: { id: shareId },
include: {
report: true,
},
});
}
if (!share || !share.public) {
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');
}
if (share.reportId !== reportId) {
throw new Error('Report ID mismatch');
const projectId = dashboardShare.projectId;
// If no password is set, share is public and accessible
if (!dashboardShare.password) {
return {
projectId,
isValid: true,
};
}
return share;
// 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');
}

View File

@@ -11,7 +11,6 @@ import {
clix,
conversionService,
createSqlBuilder,
db,
formatClickhouseDate,
funnelService,
getChartPrevStartEndDate,
@@ -24,7 +23,7 @@ import {
getSettingsForProject,
onlyReportEvents,
sankeyService,
validateReportAccess,
validateShareAccess,
} from '@openpanel/db';
import {
type IChartEvent,
@@ -334,51 +333,183 @@ 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(
zChartInput.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(
zChartInput.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(zChartInput).query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
@@ -414,76 +545,130 @@ export const chartRouter = createTRPCRouter({
chart: publicProcedure
// .use(cacher)
.input(zChartInput)
.input(
zChartInput.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(
zChartInput.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(),
@@ -494,26 +679,109 @@ 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;
criteria = report.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',
@@ -521,9 +789,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(
@@ -817,397 +1085,6 @@ export const chartRouter = createTRPCRouter({
return profiles;
}),
chartByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
// Build chart input from report, merging date overrides
const chartInput: z.infer<typeof zChartInput> = {
projectId: report.projectId,
chartType: report.chartType,
series: report.series,
breakdowns: report.breakdowns,
interval: input.interval ?? report.interval,
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
previous: report.previous,
formula: report.formula,
metric: report.metric,
};
return ChartEngine.execute(chartInput);
}),
aggregateByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
// Build chart input from report, merging date overrides
const chartInput: z.infer<typeof zChartInput> = {
projectId: report.projectId,
chartType: report.chartType,
series: report.series,
breakdowns: report.breakdowns,
interval: input.interval ?? report.interval,
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
previous: report.previous,
formula: report.formula,
metric: report.metric,
};
return AggregateChartEngine.execute(chartInput);
}),
funnelByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const currentPeriod = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([
funnelService.getFunnel({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...currentPeriod,
timezone,
funnelGroup: report.funnelGroup,
funnelWindow: report.funnelWindow,
}),
report.previous
? funnelService.getFunnel({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...previousPeriod,
timezone,
funnelGroup: report.funnelGroup,
funnelWindow: report.funnelWindow,
})
: Promise.resolve(null),
]);
return {
current,
previous,
};
}),
cohortByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const eventSeries = onlyReportEvents(report.series);
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(
String,
);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(
String,
);
if (firstEvent.length === 0 || secondEvent.length === 0) {
throw new Error('Report must have at least 2 event series');
}
const dates = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const interval = (input.interval ?? report.interval) as
| 'minute'
| 'hour'
| 'day'
| 'week'
| 'month';
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),
}[interval]();
const sqlInterval = {
minute: 'DAY',
hour: 'DAY',
day: 'DAY',
week: 'WEEK',
month: 'MONTH',
}[interval];
const sqlToStartOf = {
minute: 'toDate',
hour: 'toDate',
day: 'toDate',
week: 'toStartOfWeek',
month: 'toStartOfMonth',
}[interval];
const countCriteria =
(report.criteria ?? 'on_or_after') === 'on_or_after' ? '>=' : '=';
const usersSelect = range(0, diffInterval + 1)
.map(
(index) =>
`groupUniqArrayIf(profile_id, x_after_cohort ${countCriteria} ${index}) AS interval_${index}_users`,
)
.join(',\n');
const countsSelect = range(0, diffInterval + 1)
.map(
(index) =>
`length(interval_${index}_users) AS interval_${index}_user_count`,
)
.join(',\n');
const whereEventNameIs = (event: string[]) => {
if (event.length === 1) {
return `name = ${sqlstring.escape(event[0])}`;
}
return `name IN (${event.map((e) => sqlstring.escape(e)).join(',')})`;
};
const cohortQuery = `
WITH
cohort_users AS (
SELECT
profile_id AS userID,
project_id,
${sqlToStartOf}(created_at) AS cohort_interval
FROM ${TABLE_NAMES.cohort_events_mv}
WHERE ${whereEventNameIs(firstEvent)}
AND project_id = ${sqlstring.escape(report.projectId)}
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}')
),
last_event AS
(
SELECT
profile_id,
project_id,
toDate(created_at) AS event_date
FROM cohort_events_mv
WHERE ${whereEventNameIs(secondEvent)}
AND project_id = ${sqlstring.escape(report.projectId)}
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}') + INTERVAL ${diffInterval} ${sqlInterval}
),
retention_matrix AS
(
SELECT
f.cohort_interval,
l.profile_id,
dateDiff('${sqlInterval}', f.cohort_interval, ${sqlToStartOf}(l.event_date)) AS x_after_cohort
FROM cohort_users AS f
INNER JOIN last_event AS l ON f.userID = l.profile_id
WHERE (l.event_date >= f.cohort_interval)
AND (l.event_date <= (f.cohort_interval + INTERVAL ${diffInterval} ${sqlInterval}))
),
interval_users AS (
SELECT
cohort_interval,
${usersSelect}
FROM retention_matrix
GROUP BY cohort_interval
),
cohort_sizes AS (
SELECT
cohort_interval,
COUNT(DISTINCT userID) AS total_first_event_count
FROM cohort_users
GROUP BY cohort_interval
)
SELECT
cohort_interval,
cohort_sizes.total_first_event_count,
${countsSelect}
FROM interval_users
LEFT JOIN cohort_sizes AS cs ON cohort_interval = cs.cohort_interval
ORDER BY cohort_interval ASC
`;
const cohortData = await chQuery<{
cohort_interval: string;
total_first_event_count: number;
[key: string]: any;
}>(cohortQuery);
return processCohortData(cohortData, diffInterval);
}),
conversionByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const currentPeriod = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([
conversionService.getConversion({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...currentPeriod,
timezone,
}),
report.previous
? conversionService.getConversion({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...previousPeriod,
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,
};
}),
});
function processCohortData(

View File

@@ -2,12 +2,17 @@ import ShortUniqueId from 'short-unique-id';
import {
db,
getReportsByDashboardId,
getReportById,
getReportsByDashboardId,
getShareDashboardById,
getShareReportById,
transformReport,
} from '@openpanel/db';
import { zShareDashboard, zShareOverview, zShareReport } from '@openpanel/validation';
import {
zShareDashboard,
zShareOverview,
zShareReport,
} from '@openpanel/validation';
import { hashPassword } from '@openpanel/auth';
import { z } from 'zod';
@@ -231,11 +236,7 @@ export const shareRouter = createTRPCRouter({
name: true,
},
},
report: {
select: {
name: true,
},
},
report: true,
},
where:
'reportId' in input
@@ -257,6 +258,7 @@ export const shareRouter = createTRPCRouter({
return {
...share,
hasAccess: !!ctx.cookies[`shared-report-${share?.id}`],
report: transformReport(share.report),
};
}),

View File

@@ -86,16 +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
.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));
@@ -501,7 +497,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'),
shareType: z
.enum(['overview', 'dashboard', 'report'])
.optional()
.default('overview'),
});
export type ISignInShare = z.infer<typeof zSignInShare>;

View File

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

View File

@@ -28,7 +28,6 @@ export type IChartProps = z.infer<typeof zReportInput> & {
name: string;
lineType: IChartLineType;
unit?: string;
previousIndicatorInverted?: boolean;
};
export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartFormula = z.infer<typeof zChartFormula>;