wip
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user