diff --git a/apps/start/src/components/auth/share-enter-password.tsx b/apps/start/src/components/auth/share-enter-password.tsx index a04759bb..b25dae61 100644 --- a/apps/start/src/components/auth/share-enter-password.tsx +++ b/apps/start/src/components/auth/share-enter-password.tsx @@ -8,7 +8,13 @@ import { LogoSquare } from '../logo'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; -export function ShareEnterPassword({ shareId }: { shareId: string }) { +export function ShareEnterPassword({ + shareId, + shareType = 'overview', +}: { + shareId: string; + shareType?: 'overview' | 'dashboard' | 'report'; +}) { const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInShare.mutationOptions({ @@ -25,6 +31,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) { defaultValues: { password: '', shareId, + shareType, }, }); @@ -32,6 +39,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) { mutation.mutate({ password: data.password, shareId, + shareType, }); }); @@ -40,9 +48,20 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
-
Overview is locked
+
+ {shareType === 'dashboard' + ? 'Dashboard is locked' + : shareType === 'report' + ? 'Report is locked' + : 'Overview is locked'} +
- Please enter correct password to access this overview + Please enter correct password to access this{' '} + {shareType === 'dashboard' + ? 'dashboard' + : shareType === 'report' + ? 'report' + : 'overview'}
diff --git a/apps/start/src/components/report-chart/area/index.tsx b/apps/start/src/components/report-chart/area/index.tsx index c8d3f74a..138d7c74 100644 --- a/apps/start/src/components/report-chart/area/index.tsx +++ b/apps/start/src/components/report-chart/area/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -9,15 +10,33 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportAreaChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + shareId && shareType && 'id' in report && report.id + ? trpc.chart.chartByReport.queryOptions( + { + reportId: report.id, + shareId, + shareType, + range: range ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + interval: interval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ) + : trpc.chart.chart.queryOptions(report, { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }), ); if ( diff --git a/apps/start/src/components/report-chart/bar/index.tsx b/apps/start/src/components/report-chart/bar/index.tsx index 877f104d..bdd4349b 100644 --- a/apps/start/src/components/report-chart/bar/index.tsx +++ b/apps/start/src/components/report-chart/bar/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { cn } from '@/utils/cn'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; @@ -9,15 +10,33 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportBarChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.aggregate.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + shareId && shareType && 'id' in report && report.id + ? trpc.chart.aggregateByReport.queryOptions( + { + reportId: report.id, + shareId, + shareType, + range: range ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + interval: interval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ) + : trpc.chart.aggregate.queryOptions(report, { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }), ); if ( diff --git a/apps/start/src/components/report-chart/context.tsx b/apps/start/src/components/report-chart/context.tsx index 8dd60331..7b6a3159 100644 --- a/apps/start/src/components/report-chart/context.tsx +++ b/apps/start/src/components/report-chart/context.tsx @@ -28,9 +28,11 @@ export type ReportChartContextType = { onClick: () => void; }[]; }>; - report: IChartProps; + report: IChartProps & { id?: string }; isLazyLoading: boolean; isEditMode: boolean; + shareId?: string; + shareType?: 'dashboard' | 'report'; }; type ReportChartContextProviderProps = ReportChartContextType & { @@ -40,6 +42,8 @@ type ReportChartContextProviderProps = ReportChartContextType & { export type ReportChartProps = Partial & { report: IChartInput; lazy?: boolean; + shareId?: string; + shareType?: 'dashboard' | 'report'; }; const context = createContext(null); diff --git a/apps/start/src/components/report-chart/conversion/index.tsx b/apps/start/src/components/report-chart/conversion/index.tsx index fc75527b..fdf94a2d 100644 --- a/apps/start/src/components/report-chart/conversion/index.tsx +++ b/apps/start/src/components/report-chart/conversion/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { cn } from '@/utils/cn'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; @@ -11,15 +12,33 @@ import { Chart } from './chart'; import { Summary } from './summary'; export function ReportConversionChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); console.log(report.limit); const res = useQuery( - trpc.chart.conversion.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + shareId && shareType && 'id' in report && report.id + ? trpc.chart.conversionByReport.queryOptions( + { + reportId: report.id, + shareId, + shareType, + range: range ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + interval: interval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ) + : trpc.chart.conversion.queryOptions(report, { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }), ); if ( diff --git a/apps/start/src/components/report-chart/funnel/index.tsx b/apps/start/src/components/report-chart/funnel/index.tsx index 5fdda9c2..e7633239 100644 --- a/apps/start/src/components/report-chart/funnel/index.tsx +++ b/apps/start/src/components/report-chart/funnel/index.tsx @@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import type { RouterOutputs } from '@/trpc/client'; import { useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import type { IChartInput } from '@openpanel/validation'; import { AspectContainer } from '../aspect-container'; @@ -14,6 +15,7 @@ import { Chart, Summary, Tables } from './chart'; export function ReportFunnelChart() { const { report: { + id, series, range, projectId, @@ -25,28 +27,48 @@ export function ReportFunnelChart() { breakdowns, }, isLazyLoading, + shareId, + shareType, } = useReportChartContext(); + const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions(); - const input: IChartInput = { - series, - range, - projectId, - interval: 'day', - chartType: 'funnel', - breakdowns, - funnelWindow, - funnelGroup, - previous, - metric: 'sum', - startDate, - endDate, - limit: 20, - }; const trpc = useTRPC(); const res = useQuery( - trpc.chart.funnel.queryOptions(input, { - enabled: !isLazyLoading && input.series.length > 0, - }), + shareId && shareType && id + ? trpc.chart.funnelByReport.queryOptions( + { + reportId: id, + shareId, + shareType, + range: overviewRange ?? undefined, + startDate: overviewStartDate ?? undefined, + endDate: overviewEndDate ?? undefined, + interval: overviewInterval ?? undefined, + }, + { + enabled: !isLazyLoading && series.length > 0, + }, + ) + : (() => { + const input: IChartInput = { + series, + range, + projectId, + interval: 'day', + chartType: 'funnel', + breakdowns, + funnelWindow, + funnelGroup, + previous, + metric: 'sum', + startDate, + endDate, + limit: 20, + }; + return trpc.chart.funnel.queryOptions(input, { + enabled: !isLazyLoading && input.series.length > 0, + }); + })(), ); if (isLazyLoading || res.isLoading) { diff --git a/apps/start/src/components/report-chart/histogram/index.tsx b/apps/start/src/components/report-chart/histogram/index.tsx index 1f6d0146..d8092833 100644 --- a/apps/start/src/components/report-chart/histogram/index.tsx +++ b/apps/start/src/components/report-chart/histogram/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -9,15 +10,33 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportHistogramChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + shareId && shareType && 'id' in report && report.id + ? trpc.chart.chartByReport.queryOptions( + { + reportId: report.id, + shareId, + shareType, + range: range ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + interval: interval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ) + : trpc.chart.chart.queryOptions(report, { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }), ); if ( diff --git a/apps/start/src/components/report-chart/line/index.tsx b/apps/start/src/components/report-chart/line/index.tsx index 5c11c5d7..111e8458 100644 --- a/apps/start/src/components/report-chart/line/index.tsx +++ b/apps/start/src/components/report-chart/line/index.tsx @@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { cn } from '@/utils/cn'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -10,15 +11,33 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportLineChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + shareId && shareType && 'id' in report && report.id + ? trpc.chart.chartByReport.queryOptions( + { + reportId: report.id, + shareId, + shareType, + range: range ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + interval: interval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ) + : trpc.chart.chart.queryOptions(report, { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }), ); if ( diff --git a/apps/start/src/components/report-chart/map/index.tsx b/apps/start/src/components/report-chart/map/index.tsx index d6ca11c7..7989c07b 100644 --- a/apps/start/src/components/report-chart/map/index.tsx +++ b/apps/start/src/components/report-chart/map/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -9,15 +10,33 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportMapChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + shareId && shareType && 'id' in report && report.id + ? trpc.chart.chartByReport.queryOptions( + { + reportId: report.id, + shareId, + shareType, + range: range ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + interval: interval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ) + : trpc.chart.chart.queryOptions(report, { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }), ); if ( diff --git a/apps/start/src/components/report-chart/metric/index.tsx b/apps/start/src/components/report-chart/metric/index.tsx index 7d8e5829..c0b9153f 100644 --- a/apps/start/src/components/report-chart/metric/index.tsx +++ b/apps/start/src/components/report-chart/metric/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -8,15 +9,33 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportMetricChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + shareId && shareType && 'id' in report && report.id + ? trpc.chart.chartByReport.queryOptions( + { + reportId: report.id, + shareId, + shareType, + range: range ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + interval: interval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ) + : trpc.chart.chart.queryOptions(report, { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }), ); if ( diff --git a/apps/start/src/components/report-chart/pie/index.tsx b/apps/start/src/components/report-chart/pie/index.tsx index bf2589cc..dc58942b 100644 --- a/apps/start/src/components/report-chart/pie/index.tsx +++ b/apps/start/src/components/report-chart/pie/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -9,15 +10,33 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportPieChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.aggregate.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + shareId && shareType && 'id' in report && report.id + ? trpc.chart.aggregateByReport.queryOptions( + { + reportId: report.id, + shareId, + shareType, + range: range ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + interval: interval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ) + : trpc.chart.aggregate.queryOptions(report, { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }), ); if ( diff --git a/apps/start/src/components/report-chart/report-editor.tsx b/apps/start/src/components/report-chart/report-editor.tsx index d7ed60df..dc1a04a9 100644 --- a/apps/start/src/components/report-chart/report-editor.tsx +++ b/apps/start/src/components/report-chart/report-editor.tsx @@ -18,8 +18,10 @@ import { TimeWindowPicker } from '@/components/time-window-picker'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { useAppParams } from '@/hooks/use-app-params'; +import { pushModal } from '@/modals'; import { useDispatch, useSelector } from '@/redux'; -import { GanttChartSquareIcon } from 'lucide-react'; +import { bind } from 'bind-event-listener'; +import { GanttChartSquareIcon, ShareIcon } from 'lucide-react'; import { useEffect } from 'react'; import type { IServiceReport } from '@openpanel/db'; @@ -52,8 +54,19 @@ export default function ReportEditor({ return (
-
+
+ {initialReport?.id && ( + + )}
diff --git a/apps/start/src/components/report-chart/retention/index.tsx b/apps/start/src/components/report-chart/retention/index.tsx index 58bbffff..550b0326 100644 --- a/apps/start/src/components/report-chart/retention/index.tsx +++ b/apps/start/src/components/report-chart/retention/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -12,6 +13,7 @@ import CohortTable from './table'; export function ReportRetentionChart() { const { report: { + id, series, range, projectId, @@ -21,7 +23,10 @@ export function ReportRetentionChart() { interval, }, isLazyLoading, + shareId, + shareType, } = useReportChartContext(); + const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions(); const eventSeries = series.filter((item) => item.type === 'event'); const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String); const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String); @@ -29,23 +34,40 @@ export function ReportRetentionChart() { firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading; const trpc = useTRPC(); const res = useQuery( - trpc.chart.cohort.queryOptions( - { - firstEvent, - secondEvent, - projectId, - range, - startDate, - endDate, - criteria, - interval, - }, - { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: isEnabled, - }, - ), + shareId && shareType && id + ? trpc.chart.cohortByReport.queryOptions( + { + reportId: id, + shareId, + shareType, + range: overviewRange ?? undefined, + startDate: overviewStartDate ?? undefined, + endDate: overviewEndDate ?? undefined, + interval: overviewInterval ?? undefined, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: isEnabled, + }, + ) + : trpc.chart.cohort.queryOptions( + { + firstEvent, + secondEvent, + projectId, + range, + startDate, + endDate, + criteria, + interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: isEnabled, + }, + ), ); if (!isEnabled) { diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index e723eaf3..bc835c98 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -30,7 +30,9 @@ import OverviewFilters from './overview-filters'; import RequestPasswordReset from './request-reset-password'; import SaveReport from './save-report'; import SelectBillingPlan from './select-billing-plan'; +import ShareDashboardModal from './share-dashboard-modal'; import ShareOverviewModal from './share-overview-modal'; +import ShareReportModal from './share-report-modal'; import ViewChartUsers from './view-chart-users'; const modals = { @@ -51,6 +53,8 @@ const modals = { EditReport: EditReport, EditReference: EditReference, ShareOverviewModal: ShareOverviewModal, + ShareDashboardModal: ShareDashboardModal, + ShareReportModal: ShareReportModal, AddReference: AddReference, ViewChartUsers: ViewChartUsers, Instructions: Instructions, diff --git a/apps/start/src/modals/share-dashboard-modal.tsx b/apps/start/src/modals/share-dashboard-modal.tsx new file mode 100644 index 00000000..8ac87677 --- /dev/null +++ b/apps/start/src/modals/share-dashboard-modal.tsx @@ -0,0 +1,97 @@ +import { ButtonContainer } from '@/components/button-container'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/use-app-params'; +import { handleError } from '@/integrations/trpc/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +import { zShareDashboard } from '@openpanel/validation'; + +import { Input } from '@/components/ui/input'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +const validator = zShareDashboard; + +type IForm = z.infer; + +export default function ShareDashboardModal({ + dashboardId, +}: { + dashboardId: string; +}) { + const { projectId, organizationId } = useAppParams(); + const navigate = useNavigate(); + + const { register, handleSubmit } = useForm({ + resolver: zodResolver(validator), + defaultValues: { + public: true, + password: '', + projectId, + organizationId, + dashboardId, + }, + }); + + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const mutation = useMutation( + trpc.share.createDashboard.mutationOptions({ + onError: handleError, + onSuccess(res) { + queryClient.invalidateQueries(trpc.share.dashboard.pathFilter()); + toast('Success', { + description: `Your dashboard is now ${ + res.public ? 'public' : 'private' + }`, + action: { + label: 'View', + onClick: () => + navigate({ + to: '/share/dashboard/$shareId', + params: { + shareId: res.id, + }, + }), + }, + }); + popModal(); + }, + }), + ); + + return ( + + + { + mutation.mutate(values); + })} + > + + + + + + + + ); +} + diff --git a/apps/start/src/modals/share-report-modal.tsx b/apps/start/src/modals/share-report-modal.tsx new file mode 100644 index 00000000..c3c1f473 --- /dev/null +++ b/apps/start/src/modals/share-report-modal.tsx @@ -0,0 +1,91 @@ +import { ButtonContainer } from '@/components/button-container'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/use-app-params'; +import { handleError } from '@/integrations/trpc/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +import { zShareReport } from '@openpanel/validation'; + +import { Input } from '@/components/ui/input'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +const validator = zShareReport; + +type IForm = z.infer; + +export default function ShareReportModal({ reportId }: { reportId: string }) { + const { projectId, organizationId } = useAppParams(); + const navigate = useNavigate(); + + const { register, handleSubmit } = useForm({ + resolver: zodResolver(validator), + defaultValues: { + public: true, + password: '', + projectId, + organizationId, + reportId, + }, + }); + + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const mutation = useMutation( + trpc.share.createReport.mutationOptions({ + onError: handleError, + onSuccess(res) { + queryClient.invalidateQueries(trpc.share.report.pathFilter()); + toast('Success', { + description: `Your report is now ${res.public ? 'public' : 'private'}`, + action: { + label: 'View', + onClick: () => + navigate({ + to: '/share/report/$shareId', + params: { + shareId: res.id, + }, + }), + }, + }); + popModal(); + }, + }), + ); + + return ( + + +
{ + mutation.mutate(values); + })} + > + + + + + +
+
+ ); +} + diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index a75fc4b0..b392a3a2 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -23,7 +23,9 @@ import { Route as LoginResetPasswordRouteImport } from './routes/_login.reset-pa import { Route as LoginLoginRouteImport } from './routes/_login.login' import { Route as AppOrganizationIdRouteImport } from './routes/_app.$organizationId' import { Route as AppOrganizationIdIndexRouteImport } from './routes/_app.$organizationId.index' +import { Route as ShareReportShareIdRouteImport } from './routes/share.report.$shareId' import { Route as ShareOverviewShareIdRouteImport } from './routes/share.overview.$shareId' +import { Route as ShareDashboardShareIdRouteImport } from './routes/share.dashboard.$shareId' import { Route as StepsOnboardingProjectRouteImport } from './routes/_steps.onboarding.project' import { Route as AppOrganizationIdSettingsRouteImport } from './routes/_app.$organizationId.settings' import { Route as AppOrganizationIdBillingRouteImport } from './routes/_app.$organizationId.billing' @@ -164,11 +166,21 @@ const AppOrganizationIdIndexRoute = AppOrganizationIdIndexRouteImport.update({ path: '/', getParentRoute: () => AppOrganizationIdRoute, } as any) +const ShareReportShareIdRoute = ShareReportShareIdRouteImport.update({ + id: '/share/report/$shareId', + path: '/share/report/$shareId', + getParentRoute: () => rootRouteImport, +} as any) const ShareOverviewShareIdRoute = ShareOverviewShareIdRouteImport.update({ id: '/share/overview/$shareId', path: '/share/overview/$shareId', getParentRoute: () => rootRouteImport, } as any) +const ShareDashboardShareIdRoute = ShareDashboardShareIdRouteImport.update({ + id: '/share/dashboard/$shareId', + path: '/share/dashboard/$shareId', + getParentRoute: () => rootRouteImport, +} as any) const StepsOnboardingProjectRoute = StepsOnboardingProjectRouteImport.update({ id: '/onboarding/project', path: '/onboarding/project', @@ -498,7 +510,9 @@ export interface FileRoutesByFullPath { '/$organizationId/billing': typeof AppOrganizationIdBillingRoute '/$organizationId/settings': typeof AppOrganizationIdSettingsRoute '/onboarding/project': typeof StepsOnboardingProjectRoute + '/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute + '/share/report/$shareId': typeof ShareReportShareIdRoute '/$organizationId/': typeof AppOrganizationIdIndexRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute @@ -556,7 +570,9 @@ export interface FileRoutesByTo { '/$organizationId/billing': typeof AppOrganizationIdBillingRoute '/$organizationId/settings': typeof AppOrganizationIdSettingsRoute '/onboarding/project': typeof StepsOnboardingProjectRoute + '/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute + '/share/report/$shareId': typeof ShareReportShareIdRoute '/$organizationId': typeof AppOrganizationIdIndexRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute @@ -614,7 +630,9 @@ export interface FileRoutesById { '/_app/$organizationId/billing': typeof AppOrganizationIdBillingRoute '/_app/$organizationId/settings': typeof AppOrganizationIdSettingsRoute '/_steps/onboarding/project': typeof StepsOnboardingProjectRoute + '/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute + '/share/report/$shareId': typeof ShareReportShareIdRoute '/_app/$organizationId/': typeof AppOrganizationIdIndexRoute '/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute @@ -683,7 +701,9 @@ export interface FileRouteTypes { | '/$organizationId/billing' | '/$organizationId/settings' | '/onboarding/project' + | '/share/dashboard/$shareId' | '/share/overview/$shareId' + | '/share/report/$shareId' | '/$organizationId/' | '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/dashboards' @@ -741,7 +761,9 @@ export interface FileRouteTypes { | '/$organizationId/billing' | '/$organizationId/settings' | '/onboarding/project' + | '/share/dashboard/$shareId' | '/share/overview/$shareId' + | '/share/report/$shareId' | '/$organizationId' | '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/dashboards' @@ -798,7 +820,9 @@ export interface FileRouteTypes { | '/_app/$organizationId/billing' | '/_app/$organizationId/settings' | '/_steps/onboarding/project' + | '/share/dashboard/$shareId' | '/share/overview/$shareId' + | '/share/report/$shareId' | '/_app/$organizationId/' | '/_app/$organizationId/$projectId/chat' | '/_app/$organizationId/$projectId/dashboards' @@ -862,7 +886,9 @@ export interface RootRouteChildren { StepsRoute: typeof StepsRouteWithChildren ApiConfigRoute: typeof ApiConfigRoute ApiHealthcheckRoute: typeof ApiHealthcheckRoute + ShareDashboardShareIdRoute: typeof ShareDashboardShareIdRoute ShareOverviewShareIdRoute: typeof ShareOverviewShareIdRoute + ShareReportShareIdRoute: typeof ShareReportShareIdRoute } declare module '@tanstack/react-router' { @@ -965,6 +991,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdIndexRouteImport parentRoute: typeof AppOrganizationIdRoute } + '/share/report/$shareId': { + id: '/share/report/$shareId' + path: '/share/report/$shareId' + fullPath: '/share/report/$shareId' + preLoaderRoute: typeof ShareReportShareIdRouteImport + parentRoute: typeof rootRouteImport + } '/share/overview/$shareId': { id: '/share/overview/$shareId' path: '/share/overview/$shareId' @@ -972,6 +1005,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShareOverviewShareIdRouteImport parentRoute: typeof rootRouteImport } + '/share/dashboard/$shareId': { + id: '/share/dashboard/$shareId' + path: '/share/dashboard/$shareId' + fullPath: '/share/dashboard/$shareId' + preLoaderRoute: typeof ShareDashboardShareIdRouteImport + parentRoute: typeof rootRouteImport + } '/_steps/onboarding/project': { id: '/_steps/onboarding/project' path: '/onboarding/project' @@ -1751,7 +1791,9 @@ const rootRouteChildren: RootRouteChildren = { StepsRoute: StepsRouteWithChildren, ApiConfigRoute: ApiConfigRoute, ApiHealthcheckRoute: ApiHealthcheckRoute, + ShareDashboardShareIdRoute: ShareDashboardShareIdRoute, ShareOverviewShareIdRoute: ShareOverviewShareIdRoute, + ShareReportShareIdRoute: ShareReportShareIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx index 503cb1e8..5633bc34 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx @@ -17,6 +17,7 @@ import { MoreHorizontal, PlusIcon, RotateCcw, + ShareIcon, Trash, TrashIcon, } from 'lucide-react'; @@ -30,7 +31,7 @@ import { OverviewRange } from '@/components/overview/overview-range'; import { PageContainer } from '@/components/page-container'; import { PageHeader } from '@/components/page-header'; import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react'; -import { showConfirm } from '@/modals'; +import { pushModal, showConfirm } from '@/modals'; import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute, useRouter } from '@tanstack/react-router'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -484,6 +485,12 @@ function Component() { + pushModal('ShareDashboardModal', { dashboardId })} + > + + Share dashboard + showConfirm({ diff --git a/apps/start/src/routes/share.dashboard.$shareId.tsx b/apps/start/src/routes/share.dashboard.$shareId.tsx new file mode 100644 index 00000000..485bf438 --- /dev/null +++ b/apps/start/src/routes/share.dashboard.$shareId.tsx @@ -0,0 +1,281 @@ +import { ShareEnterPassword } from '@/components/auth/share-enter-password'; +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { LoginNavbar } from '@/components/login-navbar'; +import { ReportChart } from '@/components/report-chart'; +import { OverviewRange } from '@/components/overview/overview-range'; +import { OverviewInterval } from '@/components/overview/overview-interval'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { PageContainer } from '@/components/page-container'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, notFound, useSearch } from '@tanstack/react-router'; +import { z } from 'zod'; +import { useMemo } from 'react'; +import { Responsive, WidthProvider } from 'react-grid-layout'; +import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; +import { cn } from '@/utils/cn'; +import { timeWindows } from '@openpanel/constants'; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +type Layout = { + i: string; + x: number; + y: number; + w: number; + h: number; + minW?: number; + minH?: number; + maxW?: number; + maxH?: number; +}; + +const shareSearchSchema = z.object({ + header: z.optional(z.number().or(z.string().or(z.boolean()))), +}); + +export const Route = createFileRoute('/share/dashboard/$shareId')({ + component: RouteComponent, + validateSearch: shareSearchSchema, + loader: async ({ context, params }) => { + const share = await context.queryClient.ensureQueryData( + context.trpc.share.dashboard.queryOptions({ + shareId: params.shareId, + }), + ); + + return { share }; + }, + head: ({ loaderData }) => { + if (!loaderData || !loaderData.share) { + return { + meta: [ + { + title: 'Share not found - OpenPanel.dev', + }, + ], + }; + } + + return { + meta: [ + { + title: `${loaderData.share.dashboard?.name} - ${loaderData.share.organization?.name} - OpenPanel.dev`, + }, + ], + }; + }, + pendingComponent: FullPageLoadingState, + errorComponent: () => ( + + ), +}); + +// Report Item Component for shared view +function ReportItem({ + report, + shareId, + range, + startDate, + endDate, + interval, +}: { + report: any; + shareId: string; + range: any; + startDate: any; + endDate: any; + interval: any; +}) { + const chartRange = report.range; + + return ( +
+
+
+
{report.name}
+ {chartRange !== null && ( +
+ + {timeWindows[chartRange as keyof typeof timeWindows]?.label} + + {startDate && endDate ? ( + Custom dates + ) : ( + range !== null && + chartRange !== range && ( + + {timeWindows[range as keyof typeof timeWindows]?.label} + + ) + )} +
+ )} +
+
+
+ +
+
+ ); +} + +function RouteComponent() { + const { shareId } = Route.useParams(); + const { header } = useSearch({ from: '/share/dashboard/$shareId' }); + const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); + + const shareQuery = useSuspenseQuery( + trpc.share.dashboard.queryOptions({ + shareId, + }), + ); + + const reportsQuery = useQuery( + trpc.share.dashboardReports.queryOptions({ + shareId, + }), + ); + + const hasAccess = shareQuery.data?.hasAccess; + + if (!shareQuery.data) { + throw notFound(); + } + + if (!shareQuery.data.public) { + throw notFound(); + } + + const share = shareQuery.data; + + // Handle password protection + if (share.password && !hasAccess) { + return ( + + ); + } + + const isHeaderVisible = + header !== '0' && header !== 0 && header !== 'false' && header !== false; + + const reports = reportsQuery.data ?? []; + + // Convert reports to grid layout format for all breakpoints + const layouts = useMemo(() => { + const baseLayout = reports.map((report, index) => ({ + i: report.id, + x: report.layout?.x ?? (index % 2) * 6, + y: report.layout?.y ?? Math.floor(index / 2) * 4, + w: report.layout?.w ?? 6, + h: report.layout?.h ?? 4, + minW: 3, + minH: 3, + })); + + // Create responsive layouts for different breakpoints + return { + lg: baseLayout, + md: baseLayout, + sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })), + xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })), + xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })), + }; + }, [reports]); + + return ( +
+ {isHeaderVisible && ( +
+ +
+ )} + +
+
+
+
+ + +
+
+
+
+ {reports.length === 0 ? ( + + ) : ( +
+ + + {reports.map((report) => ( +
+ +
+ ))} +
+
+ )} +
+
+ ); +} + diff --git a/apps/start/src/routes/share.report.$shareId.tsx b/apps/start/src/routes/share.report.$shareId.tsx new file mode 100644 index 00000000..b9c7eb92 --- /dev/null +++ b/apps/start/src/routes/share.report.$shareId.tsx @@ -0,0 +1,142 @@ +import { ShareEnterPassword } from '@/components/auth/share-enter-password'; +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { LoginNavbar } from '@/components/login-navbar'; +import { ReportChart } from '@/components/report-chart'; +import { OverviewRange } from '@/components/overview/overview-range'; +import { OverviewInterval } from '@/components/overview/overview-interval'; +import { PageContainer } from '@/components/page-container'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, notFound, useSearch } from '@tanstack/react-router'; +import { z } from 'zod'; + +const shareSearchSchema = z.object({ + header: z.optional(z.number().or(z.string().or(z.boolean()))), +}); + +export const Route = createFileRoute('/share/report/$shareId')({ + component: RouteComponent, + validateSearch: shareSearchSchema, + loader: async ({ context, params }) => { + const share = await context.queryClient.ensureQueryData( + context.trpc.share.report.queryOptions({ + shareId: params.shareId, + }), + ); + + if (!share) { + return { share: null }; + } + + const report = await context.queryClient.ensureQueryData( + context.trpc.report.get.queryOptions({ + reportId: share.reportId, + }), + ); + + return { share, report }; + }, + head: ({ loaderData }) => { + if (!loaderData || !loaderData.share) { + return { + meta: [ + { + title: 'Share not found - OpenPanel.dev', + }, + ], + }; + } + + return { + meta: [ + { + title: `${loaderData.report?.name || 'Report'} - ${loaderData.share.organization?.name} - OpenPanel.dev`, + }, + ], + }; + }, + pendingComponent: FullPageLoadingState, + errorComponent: () => ( + + ), +}); + +function RouteComponent() { + const { shareId } = Route.useParams(); + const { header } = useSearch({ from: '/share/report/$shareId' }); + const trpc = useTRPC(); + const shareQuery = useSuspenseQuery( + trpc.share.report.queryOptions({ + shareId, + }), + ); + + const reportQuery = useSuspenseQuery( + trpc.report.get.queryOptions({ + reportId: shareQuery.data!.reportId, + }), + ); + + const hasAccess = shareQuery.data?.hasAccess; + + if (!shareQuery.data) { + throw notFound(); + } + + if (!shareQuery.data.public) { + throw notFound(); + } + + const share = shareQuery.data; + const report = reportQuery.data; + + // Handle password protection + if (share.password && !hasAccess) { + return ; + } + + const isHeaderVisible = + header !== '0' && header !== 0 && header !== 'false' && header !== false; + + return ( +
+ {isHeaderVisible && ( +
+ +
+ )} + +
+
+
+
+ + +
+
+
+
+
+
+
+
{report.name}
+
+
+ +
+
+
+
+
+ ); +} + diff --git a/packages/db/prisma/migrations/20260109144217_add_share_dashboard_and_report/migration.sql b/packages/db/prisma/migrations/20260109144217_add_share_dashboard_and_report/migration.sql new file mode 100644 index 00000000..11d347b4 --- /dev/null +++ b/packages/db/prisma/migrations/20260109144217_add_share_dashboard_and_report/migration.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b3985854..bafd5f94 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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 diff --git a/packages/db/src/services/share.service.ts b/packages/db/src/services/share.service.ts index a1149f52..b9b342ee 100644 --- a/packages/db/src/services/share.service.ts +++ b/packages/db/src/services/share.service.ts @@ -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; + } +} diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index ccfc7f7b..fa22019a 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -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, }); diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index e3895d61..d0294a90 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -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 = { + 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 = { + 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( diff --git a/packages/trpc/src/routers/share.ts b/packages/trpc/src/routers/share.ts index 15f9d8b0..2346111a 100644 --- a/packages/trpc/src/routers/share.ts +++ b/packages/trpc/src/routers/share.ts @@ -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, + }, + }); + }), }); diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 81f0b9b0..1f97389f 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -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; 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;