wip
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."share_dashboards" (
|
||||
"id" TEXT NOT NULL,
|
||||
"dashboardId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"public" BOOLEAN NOT NULL DEFAULT false,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."share_reports" (
|
||||
"id" TEXT NOT NULL,
|
||||
"reportId" UUID NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"public" BOOLEAN NOT NULL DEFAULT false,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_dashboards_id_key" ON "public"."share_dashboards"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_dashboards_dashboardId_key" ON "public"."share_dashboards"("dashboardId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_reports_id_key" ON "public"."share_reports"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_reports_reportId_key" ON "public"."share_reports"("reportId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."dashboards"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "public"."reports"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -46,16 +46,18 @@ model Chat {
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
projects Project[]
|
||||
members Member[]
|
||||
createdByUserId String?
|
||||
createdBy User? @relation(name: "organizationCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
createdBy User? @relation(name: "organizationCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
ProjectAccess ProjectAccess[]
|
||||
Client Client[]
|
||||
Dashboard Dashboard[]
|
||||
ShareOverview ShareOverview[]
|
||||
ShareDashboard ShareDashboard[]
|
||||
ShareReport ShareReport[]
|
||||
integrations Integration[]
|
||||
invites Invite[]
|
||||
timezone String?
|
||||
@@ -185,13 +187,15 @@ model Project {
|
||||
/// [IPrismaProjectFilters]
|
||||
filters Json @default("[]")
|
||||
|
||||
clients Client[]
|
||||
reports Report[]
|
||||
dashboards Dashboard[]
|
||||
share ShareOverview?
|
||||
meta EventMeta[]
|
||||
references Reference[]
|
||||
access ProjectAccess[]
|
||||
clients Client[]
|
||||
reports Report[]
|
||||
dashboards Dashboard[]
|
||||
share ShareOverview?
|
||||
shareDashboards ShareDashboard[]
|
||||
shareReports ShareReport[]
|
||||
meta EventMeta[]
|
||||
references Reference[]
|
||||
access ProjectAccess[]
|
||||
|
||||
notificationRules NotificationRule[]
|
||||
notifications Notification[]
|
||||
@@ -283,13 +287,14 @@ enum ChartType {
|
||||
}
|
||||
|
||||
model Dashboard {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
reports Report[]
|
||||
share ShareDashboard?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -328,6 +333,7 @@ model Report {
|
||||
dashboardId String
|
||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||
layout ReportLayout?
|
||||
share ShareReport?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -372,6 +378,38 @@ model ShareOverview {
|
||||
@@map("shares")
|
||||
}
|
||||
|
||||
model ShareDashboard {
|
||||
id String @unique
|
||||
dashboardId String @unique
|
||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("share_dashboards")
|
||||
}
|
||||
|
||||
model ShareReport {
|
||||
id String @unique
|
||||
reportId String @unique @db.Uuid
|
||||
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("share_reports")
|
||||
}
|
||||
|
||||
model EventMeta {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
|
||||
@@ -18,3 +18,100 @@ export function getShareByProjectId(projectId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboard sharing functions
|
||||
export function getShareDashboardById(id: string) {
|
||||
return db.shareDashboard.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
dashboard: {
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getShareDashboardByDashboardId(dashboardId: string) {
|
||||
return db.shareDashboard.findUnique({
|
||||
where: {
|
||||
dashboardId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Report sharing functions
|
||||
export function getShareReportById(id: string) {
|
||||
return db.shareReport.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
report: {
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getShareReportByReportId(reportId: string) {
|
||||
return db.shareReport.findUnique({
|
||||
where: {
|
||||
reportId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Validation for secure endpoints
|
||||
export async function validateReportAccess(
|
||||
reportId: string,
|
||||
shareId: string,
|
||||
shareType: 'dashboard' | 'report',
|
||||
) {
|
||||
if (shareType === 'dashboard') {
|
||||
const share = await db.shareDashboard.findUnique({
|
||||
where: { id: shareId },
|
||||
include: {
|
||||
dashboard: {
|
||||
include: {
|
||||
reports: {
|
||||
where: { id: reportId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!share || !share.public) {
|
||||
throw new Error('Share not found or not public');
|
||||
}
|
||||
|
||||
if (!share.dashboard.reports.some((r) => r.id === reportId)) {
|
||||
throw new Error('Report does not belong to this dashboard');
|
||||
}
|
||||
|
||||
return share;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -246,6 +246,22 @@ export const zShareOverview = z.object({
|
||||
public: z.boolean(),
|
||||
});
|
||||
|
||||
export const zShareDashboard = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
dashboardId: z.string(),
|
||||
password: z.string().nullable(),
|
||||
public: z.boolean(),
|
||||
});
|
||||
|
||||
export const zShareReport = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
reportId: z.string(),
|
||||
password: z.string().nullable(),
|
||||
public: z.boolean(),
|
||||
});
|
||||
|
||||
export const zCreateReference = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullish(),
|
||||
@@ -485,6 +501,7 @@ 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'),
|
||||
});
|
||||
export type ISignInShare = z.infer<typeof zSignInShare>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user