This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-09 19:58:24 +01:00
parent ca15717885
commit ba79ac570c
27 changed files with 1826 additions and 108 deletions

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

@@ -19,10 +19,12 @@ import {
getEventFiltersWhereClause,
getEventMetasCached,
getProfilesCached,
getReportById,
getSelectPropertyKey,
getSettingsForProject,
onlyReportEvents,
sankeyService,
validateReportAccess,
} from '@openpanel/db';
import {
type IChartEvent,
@@ -815,6 +817,397 @@ 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

@@ -1,11 +1,18 @@
import ShortUniqueId from 'short-unique-id';
import { db } from '@openpanel/db';
import { zShareOverview } from '@openpanel/validation';
import {
db,
getReportsByDashboardId,
getReportById,
getShareDashboardById,
getShareReportById,
} 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 +92,206 @@ 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: {
select: {
name: 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}`],
};
}),
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,
},
});
}),
});