From 638e92d53447ba1d43b15b1a43b3b74a38ebfde1 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:50:35 +0000 Subject: [PATCH] feat: add team document analytics dashboard (#3355) --- .../analytics-activity-table-card.tsx | 371 +++++++ .../analytics-documents-over-time-card.tsx | 203 ++++ .../analytics/analytics-hydrate-fallback.tsx | 17 + .../analytics/analytics-no-activity-alert.tsx | 45 + .../analytics/analytics-overview-cards.tsx | 214 ++++ .../analytics/analytics-page-header.tsx | 67 ++ .../analytics/analytics-query-error.tsx | 37 + .../analytics/analytics-range-picker.tsx | 264 +++++ .../general/analytics/analytics-stat-card.tsx | 63 ++ .../analytics-status-breakdown-card.tsx | 198 ++++ .../analytics-template-usage-card.tsx | 145 +++ .../app/components/general/app-nav-mobile.tsx | 24 +- .../app/components/general/filter-pill.tsx | 8 +- .../app/components/general/metric-card.tsx | 5 +- .../components/general/org-menu-switcher.tsx | 23 +- .../_authenticated+/o.$orgUrl._index.tsx | 28 +- .../o.$orgUrl.analytics._index.tsx | 179 ++++ .../t.$teamUrl+/analytics._index.tsx | 183 ++++ apps/remix/app/utils/analytics.ts | 193 ++++ ...test-unauthorized-analytics-access.spec.ts | 545 ++++++++++ .../organisation-analytics.spec.ts | 289 ++++++ .../e2e/teams/team-analytics.spec.ts | 690 +++++++++++++ ...anisation-analytics-documents-over-time.ts | 81 ++ .../get-organisation-analytics-overview.ts | 117 +++ .../get-organisation-analytics-scope.ts | 101 ++ ...organisation-analytics-status-breakdown.ts | 69 ++ ...et-organisation-analytics-team-activity.ts | 117 +++ ...t-organisation-analytics-template-usage.ts | 127 +++ .../get-team-analytics-documents-over-time.ts | 81 ++ .../get-team-analytics-member-activity.ts | 126 +++ .../team/get-team-analytics-overview.ts | 127 +++ .../team/get-team-analytics-scope.ts | 202 ++++ .../get-team-analytics-status-breakdown.ts | 70 ++ .../team/get-team-analytics-template-usage.ts | 119 +++ packages/lib/utils/organisations.ts | 14 +- .../lib/utils/team-analytics-range.test.ts | 323 ++++++ packages/lib/utils/team-analytics-range.ts | 174 ++++ packages/lib/utils/teams.ts | 4 + packages/prisma/seed/analytics-seed.ts | 941 ++++++++++++++++++ ...anisation-analytics-documents-over-time.ts | 32 + .../get-organisation-analytics-overview.ts | 32 + ...organisation-analytics-status-breakdown.ts | 32 + ...et-organisation-analytics-team-activity.ts | 32 + ...t-organisation-analytics-template-usage.ts | 34 + .../get-organisation-analytics.types.ts | 161 +++ .../trpc/server/organisation-router/router.ts | 12 + .../get-team-analytics-documents-over-time.ts | 33 + .../get-team-analytics-member-activity.ts | 33 + .../get-team-analytics-overview.ts | 33 + .../get-team-analytics-status-breakdown.ts | 33 + .../get-team-analytics-template-usage.ts | 35 + .../team-router/get-team-analytics.types.ts | 198 ++++ packages/trpc/server/team-router/router.ts | 12 + 53 files changed, 7275 insertions(+), 21 deletions(-) create mode 100644 apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-overview-cards.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-page-header.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-query-error.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-range-picker.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-stat-card.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx create mode 100644 apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx create mode 100644 apps/remix/app/routes/_authenticated+/o.$orgUrl.analytics._index.tsx create mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/analytics._index.tsx create mode 100644 apps/remix/app/utils/analytics.ts create mode 100644 packages/app-tests/e2e/api/trpc/test-unauthorized-analytics-access.spec.ts create mode 100644 packages/app-tests/e2e/organisations/organisation-analytics.spec.ts create mode 100644 packages/app-tests/e2e/teams/team-analytics.spec.ts create mode 100644 packages/lib/server-only/organisation/get-organisation-analytics-documents-over-time.ts create mode 100644 packages/lib/server-only/organisation/get-organisation-analytics-overview.ts create mode 100644 packages/lib/server-only/organisation/get-organisation-analytics-scope.ts create mode 100644 packages/lib/server-only/organisation/get-organisation-analytics-status-breakdown.ts create mode 100644 packages/lib/server-only/organisation/get-organisation-analytics-team-activity.ts create mode 100644 packages/lib/server-only/organisation/get-organisation-analytics-template-usage.ts create mode 100644 packages/lib/server-only/team/get-team-analytics-documents-over-time.ts create mode 100644 packages/lib/server-only/team/get-team-analytics-member-activity.ts create mode 100644 packages/lib/server-only/team/get-team-analytics-overview.ts create mode 100644 packages/lib/server-only/team/get-team-analytics-scope.ts create mode 100644 packages/lib/server-only/team/get-team-analytics-status-breakdown.ts create mode 100644 packages/lib/server-only/team/get-team-analytics-template-usage.ts create mode 100644 packages/lib/utils/team-analytics-range.test.ts create mode 100644 packages/lib/utils/team-analytics-range.ts create mode 100644 packages/prisma/seed/analytics-seed.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-analytics-documents-over-time.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-analytics-overview.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-analytics-status-breakdown.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-analytics-team-activity.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-analytics-template-usage.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-analytics.types.ts create mode 100644 packages/trpc/server/team-router/get-team-analytics-documents-over-time.ts create mode 100644 packages/trpc/server/team-router/get-team-analytics-member-activity.ts create mode 100644 packages/trpc/server/team-router/get-team-analytics-overview.ts create mode 100644 packages/trpc/server/team-router/get-team-analytics-status-breakdown.ts create mode 100644 packages/trpc/server/team-router/get-team-analytics-template-usage.ts create mode 100644 packages/trpc/server/team-router/get-team-analytics.types.ts diff --git a/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx b/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx new file mode 100644 index 000000000..7e1476a77 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-activity-table-card.tsx @@ -0,0 +1,371 @@ +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Input } from '@documenso/ui/primitives/input'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { LucideIcon } from 'lucide-react'; +import { SearchIcon, UsersIcon } from 'lucide-react'; +import type { MouseEvent, ReactNode } from 'react'; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; +import { formatRelativeDate } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */ +export type AnalyticsActivityRow = { + key: string | number; + avatar: { + imageId: string | null; + fallback: string; + }; + title: string; + subtitle?: string | null; + sent: number; + completed: number; + pending: number; + /** 0-100, null when nothing was sent. */ + completionRate: number | null; + lastActiveAt: Date | null; + /** When set the whole row navigates here and the title becomes a link. */ + href?: string; +}; + +export type AnalyticsActivityTableCardProps = { + query: AnalyticsQueryResult; + /** Rows derived from `query.data`; empty while loading. */ + rows: AnalyticsActivityRow[]; + /** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */ + rangeKey: string; + title: ReactNode; + description: ReactNode; + /** Header of the first column, e.g. "Member". */ + columnLabel: ReactNode; + /** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */ + renderSummary: (count: number, activeCount: number) => ReactNode; + /** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */ + renderShowing: (visibleCount: number, totalCount: number) => ReactNode; + emptyLabel: ReactNode; + emptyIcon?: LucideIcon; + /** Placeholder for the search input, e.g. "Search members". */ + searchPlaceholder: string; + /** Rendered when the search matches nothing, e.g. "No members match your search". */ + noSearchResultsLabel: ReactNode; + /** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */ + testIdPrefix: string; + className?: string; +}; + +export const AnalyticsActivityTableCard = ({ + query, + rows, + rangeKey, + title, + description, + columnLabel, + renderSummary, + renderShowing, + emptyLabel, + emptyIcon: EmptyIcon = UsersIcon, + searchPlaceholder, + noSearchResultsLabel, + testIdPrefix, + className, +}: AnalyticsActivityTableCardProps) => { + const { i18n } = useLingui(); + + // Tracks which window "Show all" was pressed for, so it resets whenever the window changes. + const [expandedRangeKey, setExpandedRangeKey] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + const isExpanded = expandedRangeKey === rangeKey; + + const { data, isLoading, isError, refetch } = query; + + const activeCount = rows.filter((row) => row.sent > 0).length; + + const normalisedSearchTerm = searchTerm.trim().toLowerCase(); + const isSearching = normalisedSearchTerm.length > 0; + + // Search always shows every match; the preview limit only applies to the unfiltered list. + const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows; + const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT); + const hasHiddenRows = filteredRows.length > visibleRows.length; + + const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`; + + return ( + + +
+ {title} + + {description} +
+ + {data !== undefined && rows.length > 0 && ( +

+ {renderSummary(rows.length, activeCount)} +

+ )} +
+ + + {isError ? ( + + ) : isLoading || data === undefined ? ( +
    + {Array.from({ length: 4 }, (_, index) => ( +
  • + + +
    + + +
    + + + + + + +
  • + ))} +
+ ) : rows.length === 0 ? ( +
+
+
+ +

{emptyLabel}

+
+ ) : ( +
+
+
+ + {filteredRows.length === 0 ? ( +

+ {noSearchResultsLabel} +

+ ) : ( + <> + {/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */} +
+ + + + {columnLabel} + + Sent + + + Completed + + + Pending + + + Completion rate + + + Last active + + + + + + {visibleRows.map((row) => ( + + ))} + +
+
+ + {hasHiddenRows && ( +
+

+ {renderShowing(visibleRows.length, filteredRows.length)} +

+ + +
+ )} + + )} +
+ )} +
+
+ ); +}; + +type ActivityRowProps = { + row: AnalyticsActivityRow; + locale: string; + testIdPrefix: string; +}; + +const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => { + const { _ } = useLingui(); + const navigate = useNavigate(); + + const isActive = row.sent > 0; + + const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`; + + // Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout. + const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground'); + const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums'; + + // role="img" so the aria-label is valid (a bare span has no role that supports it). + const notAvailable = ( + + — + + ); + + /** + * The title link is the accessible target; clicking anywhere else on the row + * navigates too. Modifier clicks and clicks on the link itself are left to the + * browser so open-in-new-tab keeps working, and drag-selecting text does not + * navigate. + */ + const handleRowClick = (event: MouseEvent) => { + if (!row.href || event.defaultPrevented || event.button !== 0) { + return; + } + + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + if (event.target instanceof Element && event.target.closest('a')) { + return; + } + + if (window.getSelection()?.toString()) { + return; + } + + void navigate(row.href); + }; + + return ( + + {/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */} + +
+ + {row.avatar.imageId && } + {row.avatar.fallback} + + +
+ {row.href ? ( + + {row.title} + + ) : ( + + {row.title} + + )} + + {row.subtitle && {row.subtitle}} +
+
+
+ + + {row.sent.toLocaleString(locale)} + + + + {row.completed.toLocaleString(locale)} + + + + {row.pending.toLocaleString(locale)} + + + + {row.completionRate === null ? ( + notAvailable + ) : ( +
+ + )} + + + + {row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)} + + + ); +}; + +const ROW_PREVIEW_LIMIT = 8; + +const matchesSearch = (row: AnalyticsActivityRow, term: string) => { + return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term); +}; + +/** + * The table is pulled out to the card edge (-mx-6), so the outer cells get the + * card's px-6 gutter to line up with the header. "Last active" is hidden below + * md, so "Completion rate" takes the right gutter there. + */ +const FIRST_CELL_CLASS = '!pl-6'; +const LAST_CELL_CLASS = '!pr-6'; +const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4'; diff --git a/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx b/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx new file mode 100644 index 000000000..f122afe39 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-documents-over-time-card.tsx @@ -0,0 +1,203 @@ +import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { BarChart3Icon } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; + +import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics'; +import { getAnalyticsDateRangeDays } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsDocumentsOverTimeCardProps = { + range: AnalyticsRangeValue; + query: AnalyticsQueryResult; + className?: string; +}; + +type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket']; + +export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + // The backend decides the bucket, and it must match the points being rendered so + // the tick and tooltip formatting line up. Before data arrives it is guessed from + // the requested range. + const bucket: Bucket = data ? data.range.bucket : guessBucket(range); + + const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0; + + return ( + + +
+ + Documents created + + + {bucket === 'month' ? Monthly : Daily} +
+ + {data && ( +

+ + {data.total.toLocaleString(i18n.locale)} + {' '} + + total + +

+ )} +
+ + {/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */} + + {isError ? ( + + ) : isLoading || !data ? ( + + ) : data.total === 0 ? ( +
+
+
+ +

+ No documents created in this period +

+
+ ) : ( + + + + + formatTickLabel(value, bucket, i18n.locale)} + /> + + + + } + cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }} + /> + + + + + )} +
+
+ ); +}; + +type DocumentsOverTimeTooltipProps = { + active?: boolean; + payload?: Array<{ payload: { date: string; count: number } }>; + bucket: Bucket; + locale: string; +}; + +const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => { + const point = payload?.[0]?.payload; + + if (!active || !point) { + return null; + } + + const count = Number(point.count ?? 0); + + return ( +
+

{formatTooltipLabel(point.date, bucket, locale)}

+ +

+ +

+
+ ); +}; + +const CHART_HEIGHT = 240; + +const TARGET_DAILY_TICK_COUNT = 6; + +/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */ +const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92; + +const guessBucket = (range: AnalyticsRangeValue): Bucket => { + if (range.range === '12m') { + return 'month'; + } + + if (range.range === 'custom') { + return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day'; + } + + return 'day'; +}; + +/** + * Month buckets label every month (12 fit at the lg width) and let recharts drop + * overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels. + */ +const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => { + if (bucket === 'month') { + return 'preserveStartEnd'; + } + + if (pointCount <= TARGET_DAILY_TICK_COUNT) { + return 0; + } + + return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1); +}; + +const formatTickLabel = (date: string, bucket: Bucket, locale: string) => { + const parsed = DateTime.fromISO(date).setLocale(locale); + + if (bucket === 'month') { + return parsed.toLocaleString({ month: 'short' }); + } + + return parsed.toLocaleString({ month: 'short', day: 'numeric' }); +}; + +const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => { + const parsed = DateTime.fromISO(date).setLocale(locale); + + if (bucket === 'month') { + return parsed.toLocaleString({ month: 'long', year: 'numeric' }); + } + + return parsed.toLocaleString(DateTime.DATE_FULL); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx b/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx new file mode 100644 index 000000000..727284d35 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-hydrate-fallback.tsx @@ -0,0 +1,17 @@ +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { Trans } from '@lingui/react/macro'; + +/** + * Shown while the analytics route's `clientLoader` resolves the browser timezone + * during hydration. + */ +export const AnalyticsHydrateFallback = () => { + return ( +
+ + + Loading analytics + +
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx b/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx new file mode 100644 index 000000000..4eeae6387 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-no-activity-alert.tsx @@ -0,0 +1,45 @@ +import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { InfoIcon } from 'lucide-react'; + +import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics'; + +export type AnalyticsNoActivityAlertProps = { + range: TTeamAnalyticsRange; + /** Invoked when the user asks to widen the range to the last 12 months. */ + onShowLastYear: () => void; +}; + +export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => { + const { _ } = useLingui(); + + const canWidenRange = range !== '12m'; + + return ( + + + + + + {canWidenRange && ( + + )} + + + ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx b/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx new file mode 100644 index 000000000..e7acfad18 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-overview-cards.tsx @@ -0,0 +1,214 @@ +import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { LucideIcon } from 'lucide-react'; +import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; + +import { AnalyticsStatCard } from './analytics-stat-card'; + +/** The part of the overview response shared by the team and organisation procedures. */ +export type AnalyticsOverviewData = Pick; + +/** + * The third card counts the scope's "entities" (team members, organisation teams) + * and how many of them were active in the period. + */ +export type AnalyticsOverviewEntityCard = { + icon: LucideIcon; + title: ReactNode; + /** Applied to the value element, e.g. `analytics-members`. */ + testId: string; + select: (data: TData) => { active: number; total: number }; +}; + +export type AnalyticsOverviewCardsProps = { + query: AnalyticsQueryResult; + entity: AnalyticsOverviewEntityCard; +}; + +export const AnalyticsOverviewCards = ({ + query, + entity, +}: AnalyticsOverviewCardsProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + const entityCounts = data ? entity.select(data) : null; + + const formatNumber = (value: number) => value.toLocaleString(i18n.locale); + + const sharedProps = { + isLoading: isLoading || !data, + isError, + onRetry: refetch, + }; + + return ( +
+ Documents sent} + value={data ? formatNumber(data.sent.current) : null} + badge={data ? : null} + description={vs. previous period} + testId="analytics-sent" + /> + + Completion rate} + value={data ? formatRate(data.completionRate.rate) : null} + badge={ + data ? ( + + ) : null + } + description={of sent documents completed} + testId="analytics-completion-rate" + /> + + + {formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '} + inactive + + ) : null + } + testId={entity.testId} + /> +
+ ); +}; + +type SentDeltaBadgeProps = { + current: number; + previous: number; +}; + +const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => { + if (previous === 0 && current === 0) { + return null; + } + + if (previous === 0) { + return ( + + New + + ); + } + + const delta = Math.round(((current - previous) / previous) * 100); + + // Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display. + const label = + delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`; + + return ( + + {label} + + ); +}; + +type CompletionRateDeltaBadgeProps = { + rate: number | null; + previousRate: number | null; +}; + +const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => { + if (rate === null || previousRate === null) { + return null; + } + + // Compare the rounded values so the delta always agrees with the displayed rate. + const delta = Math.round(rate) - Math.round(previousRate); + + return ( + + {formatSignedNumber(delta)}% + + ); +}; + +type DeltaTone = 'positive' | 'negative' | 'zero' | 'new'; + +type DeltaBadgeProps = { + tone: DeltaTone; + testId: string; + children: ReactNode; +}; + +const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => { + const DeltaIcon = DELTA_TONE_ICONS[tone]; + + return ( + + {DeltaIcon && + ); +}; + +const DELTA_TONE_CLASSES: Record = { + positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + negative: 'bg-red-500/10 text-red-600 dark:text-red-400', + zero: 'bg-muted text-muted-foreground', + new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', +}; + +const MAX_DISPLAYED_DELTA_PERCENT = 999; + +const DELTA_TONE_ICONS: Record = { + positive: ArrowUpRightIcon, + negative: ArrowDownRightIcon, + zero: null, + new: null, +}; + +const formatRate = (rate: number | null) => { + if (rate === null) { + return '—'; + } + + return `${Math.round(rate)}%`; +}; + +const formatSignedNumber = (value: number) => { + if (value > 0) { + return `+${value}`; + } + + return String(value); +}; + +const getDeltaTone = (delta: number): DeltaTone => { + if (delta > 0) { + return 'positive'; + } + + if (delta < 0) { + return 'negative'; + } + + return 'zero'; +}; diff --git a/apps/remix/app/components/general/analytics/analytics-page-header.tsx b/apps/remix/app/components/general/analytics/analytics-page-header.tsx new file mode 100644 index 000000000..667d0c85b --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-page-header.tsx @@ -0,0 +1,67 @@ +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { ReactNode } from 'react'; + +import type { AnalyticsRangeValue } from '~/utils/analytics'; +import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics'; + +import { AnalyticsRangePicker } from './analytics-range-picker'; + +export type AnalyticsPageHeaderProps = { + avatarImageId: string | null; + /** The team or organisation name. */ + name: string; + range: AnalyticsRangeValue; + onRangeChange: (range: AnalyticsRangeValue) => void; + /** Rendered before the range picker, e.g. a link to a related analytics page. */ + actions?: ReactNode; + className?: string; +}; + +export const AnalyticsPageHeader = ({ + avatarImageId, + name, + range, + onRangeChange, + actions, + className, +}: AnalyticsPageHeaderProps) => { + const { _, i18n } = useLingui(); + + const rangeLabel = + range.range === 'custom' + ? formatAnalyticsDateRange(range.from, range.to, i18n.locale) + : _(ANALYTICS_RANGE_LABELS[range.range]); + + return ( +
+
+ + {avatarImageId && } + {name.slice(0, 1)} + + +
+

+ Analytics +

+ +

+ + Usage overview for {name} · {rangeLabel} + +

+
+
+ +
+ {actions} + + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-query-error.tsx b/apps/remix/app/components/general/analytics/analytics-query-error.tsx new file mode 100644 index 000000000..41dd48030 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-query-error.tsx @@ -0,0 +1,37 @@ +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type AnalyticsQueryErrorProps = { + onRetry: () => Promise; + className?: string; +}; + +export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => { + const [isRetrying, setIsRetrying] = useState(false); + + const handleRetry = async () => { + setIsRetrying(true); + + try { + await onRetry(); + } finally { + setIsRetrying(false); + } + }; + + return ( + + + + This data could not be loaded. + + + + + + ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-range-picker.tsx b/apps/remix/app/components/general/analytics/analytics-range-picker.tsx new file mode 100644 index 000000000..ff2ed33a4 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-range-picker.tsx @@ -0,0 +1,264 @@ +import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size'; +import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Button } from '@documenso/ui/primitives/button'; +import type { CalendarProps } from '@documenso/ui/primitives/calendar'; +import { Calendar } from '@documenso/ui/primitives/calendar'; +import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover'; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { DateTime } from 'luxon'; +import { useRef, useState } from 'react'; + +import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics'; +import { + ANALYTICS_PRESET_RANGES, + ANALYTICS_RANGE_LABELS, + formatAnalyticsDate, + formatAnalyticsDateRange, + getAnalyticsDateRangeDays, +} from '~/utils/analytics'; + +export type AnalyticsRangePickerProps = { + value: AnalyticsRangeValue; + onValueChange: (value: AnalyticsRangeValue) => void; +}; + +/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */ +type DraftRange = { + from: Date | undefined; + to: Date | undefined; +}; + +/** A single react-day-picker matcher, e.g. `{ after: Date }`. */ +type DayMatcher = Exclude; + +/** + * A preset select with a "Custom range…" item that opens a two month range + * calendar anchored to the select. The custom window is only committed when + * "Apply" is pressed. + */ +export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => { + const { _, i18n } = useLingui(); + const { width } = useWindowSize(); + + const triggerRef = useRef(null); + const contentRef = useRef(null); + + const [isPickerOpen, setIsPickerOpen] = useState(false); + const [draft, setDraft] = useState(); + + const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1; + + const today = DateTime.local().startOf('day'); + + const openPicker = () => { + setDraft( + value.range === 'custom' + ? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() } + : undefined, + ); + + setIsPickerOpen(true); + }; + + const closePicker = () => { + setIsPickerOpen(false); + setDraft(undefined); + }; + + const handleSelectValueChange = (nextValue: string) => { + if (nextValue === CUSTOM_RANGE_VALUE) { + openPicker(); + + return; + } + + const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue); + + if (!preset) { + return; + } + + onValueChange({ range: preset }); + }; + + /** + * Picking a day starts a new window unless one end is already pending, in which + * case it completes it. This replaces react-day-picker's default, which extends + * a completed window instead of starting over. + */ + const handleDaySelect = (_nextRange: unknown, day: Date) => { + if (draft?.from && !draft.to) { + setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day }); + + return; + } + + setDraft({ from: day, to: undefined }); + }; + + const handleApply = () => { + if (!draft?.from || !draft.to) { + return; + } + + onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) }); + + closePicker(); + }; + + // Only the last year (plus a day) up to today is selectable. + const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK); + const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }]; + + // Open on the month of the pending window (or today), keeping the current month + // as the right-most one so no fully disabled future month is shown. + const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month'); + const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 }); + const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate(); + + const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null; + const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null; + const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0; + + const customLabel = + value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined; + + return ( + { + if (!open) { + closePicker(); + } + }} + > + {/* + * The select never holds "custom" as its value so choosing "Custom range…" always + * fires a change, letting an active custom window be adjusted. The trigger shows + * the formatted window through the placeholder instead. + */} + + + { + if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) { + return; + } + + event.preventDefault(); + + const content = contentRef.current; + const firstTabbable = content?.querySelector(TABBABLE_SELECTOR); + + (firstTabbable ?? content)?.focus(); + }} + // There is no popover trigger element, so hand focus back to the select. + onCloseAutoFocus={(event) => { + event.preventDefault(); + triggerRef.current?.focus(); + }} + > +
+ +
+ +
+

+ {draftFrom && draftTo ? ( + <> + {formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '} + + + ) : draftFrom ? ( + Pick an end date + ) : ( + Pick a start date + )} +

+ +
+ + + +
+
+
+
+ ); +}; + +const CUSTOM_RANGE_VALUE = 'custom'; + +/** Tailwind `sm` breakpoint; two months are shown from here up. */ +const SM_BREAKPOINT = 640; + +/** First element the popover should focus: the calendar's month navigation, then the days. */ +const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]'; + +const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({ + value, + label: ANALYTICS_RANGE_LABELS[value], +})); diff --git a/apps/remix/app/components/general/analytics/analytics-stat-card.tsx b/apps/remix/app/components/general/analytics/analytics-stat-card.tsx new file mode 100644 index 000000000..b0f3ba814 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-stat-card.tsx @@ -0,0 +1,63 @@ +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsStatCardProps = { + icon: LucideIcon; + title: ReactNode; + value: ReactNode; + description: ReactNode; + badge?: ReactNode; + isLoading: boolean; + isError: boolean; + onRetry: () => Promise; + testId: string; +}; + +export const AnalyticsStatCard = ({ + icon: Icon, + title, + value, + description, + badge, + isLoading, + isError, + onRetry, + testId, +}: AnalyticsStatCardProps) => { + return ( + + +
+

{title}

+ +
+ + {isError ? ( + + ) : isLoading ? ( +
+ + +
+ ) : ( + <> +
+

+ {value} +

+ + {badge} +
+ +

{description}

+ + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx b/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx new file mode 100644 index 000000000..38bd260e8 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-status-breakdown-card.tsx @@ -0,0 +1,198 @@ +import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +export type AnalyticsStatusBreakdownCardProps = { + query: AnalyticsQueryResult; + className?: string; +}; + +export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => { + const { _, i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : []; + + return ( + + +
+ + Status breakdown + + + + Documents created in this period + +
+ + {data && ( +

+ + {data.total.toLocaleString(i18n.locale)} + {' '} + + total + +

+ )} +
+ + + {isError ? ( + + ) : isLoading || !data ? ( +
+ + +
+ {STATUS_ROWS.slice(0, 3).map((row) => ( + + ))} +
+
+ ) : data.total === 0 ? ( +
+ + +

+ No documents in this period +

+
+ ) : ( +
+ + +
    + {rows.map((row) => ( +
  • +
    +
    + +
    + + {row.count.toLocaleString(i18n.locale)} + + {row.percent}% +
    +
  • + ))} +
+
+ )} +
+
+ ); +}; + +type StatusBarProps = { + segments: Array<{ key: string; percent: number; color: string }>; + label: string; +}; + +/** + * Stacked horizontal bar. Segment widths come from the largest-remainder + * percentages so they always add up to the full width; an empty list renders + * the muted track on its own. + */ +const StatusBar = ({ segments, label }: StatusBarProps) => { + return ( +
+ {segments.map((segment) => ( +
+ ))} +
+ ); +}; + +type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled'; + +type StatusRow = { + key: StatusKey; + label: MessageDescriptor; + color: string; +}; + +/** + * Single source of truth for status colours so the bar and the legend cannot drift. + */ +const STATUS_ROWS: StatusRow[] = [ + { key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' }, + { key: 'pending', label: msg`Pending`, color: '#f59e0b' }, + { key: 'rejected', label: msg`Rejected`, color: '#ef4444' }, + { key: 'cancelled', label: msg`Cancelled`, color: '#f97316' }, + { key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' }, +]; + +/** + * Assign integer percentages to the non-zero rows using largest-remainder + * allocation so the values always sum to exactly 100, with every non-zero row + * shown as at least 1%. + */ +const allocatePercentages = (rows: T[]): Array => { + const visibleRows = rows.filter((row) => row.count > 0); + const total = visibleRows.reduce((sum, row) => sum + row.count, 0); + + if (total === 0) { + return []; + } + + const allocations = visibleRows.map((row, index) => { + const exact = (row.count / total) * 100; + const floored = Math.floor(exact); + + return { index, percent: floored, remainder: exact - floored }; + }); + + let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0); + + const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index); + + for (const allocation of byRemainder) { + if (remaining <= 0) { + break; + } + + allocation.percent += 1; + remaining -= 1; + } + + // Every non-zero row must display at least 1%; take the difference from the largest rows. + const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index); + + for (const allocation of allocations) { + if (allocation.percent > 0) { + continue; + } + + allocation.percent = 1; + + const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1); + + if (donor) { + donor.percent -= 1; + } + } + + return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent })); +}; diff --git a/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx b/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx new file mode 100644 index 000000000..9f4837419 --- /dev/null +++ b/apps/remix/app/components/general/analytics/analytics-template-usage-card.tsx @@ -0,0 +1,145 @@ +import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { FileTextIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; + +import type { AnalyticsQueryResult } from '~/utils/analytics'; +import { formatRelativeDate } from '~/utils/analytics'; + +import { AnalyticsQueryError } from './analytics-query-error'; + +/** The template shape shared by the team and organisation procedures. */ +export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number]; + +export type AnalyticsTemplateUsageCardProps = { + query: AnalyticsQueryResult<{ templates: TTemplate[] }>; + /** Where the template title links to. Return null to render a plain title. */ + getTemplateHref: (template: TTemplate) => string | null; + /** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */ + renderTemplateMeta?: (template: TTemplate) => ReactNode; + /** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */ + templatesHref?: string; + className?: string; +}; + +export const AnalyticsTemplateUsageCard = ({ + query, + getTemplateHref, + renderTemplateMeta, + templatesHref, + className, +}: AnalyticsTemplateUsageCardProps) => { + const { i18n } = useLingui(); + + const { data, isLoading, isError, refetch } = query; + + return ( + + + + Template usage + + + + Documents created from templates + + + + + {isError ? ( + + ) : isLoading || !data ? ( +
    + {Array.from({ length: 3 }, (_, index) => ( +
  • + + + +
    + + +
    + + +
  • + ))} +
+ ) : data.templates.length === 0 ? ( +
+
+
+ +

+ No documents were created from templates in this period +

+ + {templatesHref && ( + + )} +
+ ) : ( +
    + {data.templates.map((template, index) => { + const href = template.title === null ? null : getTemplateHref(template); + const meta = renderTemplateMeta?.(template); + + return ( +
  1. + + +
    +
    + +
    + {template.title === null ? ( + + Unavailable template + + ) : href !== null ? ( + + {template.title} + + ) : ( + {template.title} + )} + + {(meta || template.updatedAt !== null) && ( + + {meta} + {meta && template.updatedAt !== null && ' · '} + {template.updatedAt !== null && ( + Updated {formatRelativeDate(template.updatedAt, i18n.locale)} + )} + + )} +
    + + + + +
  2. + ); + })} +
+ )} +
+
+ ); +}; diff --git a/apps/remix/app/components/general/app-nav-mobile.tsx b/apps/remix/app/components/general/app-nav-mobile.tsx index 44f81f639..76eea8c24 100644 --- a/apps/remix/app/components/general/app-nav-mobile.tsx +++ b/apps/remix/app/components/general/app-nav-mobile.tsx @@ -1,6 +1,9 @@ import LogoImage from '@documenso/assets/logo.png'; import { authClient } from '@documenso/auth/client'; +import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations'; +import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; @@ -22,6 +25,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps const { organisations } = useSession(); const currentTeam = useOptionalCurrentTeam(); + const currentOrganisation = useOptionalCurrentOrganisation(); const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery( { @@ -37,18 +41,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps }; const menuNavigationLinks = useMemo(() => { - let teamUrl = currentTeam?.url || null; + const navigationTeam = + currentTeam ?? + (organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null); - if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) { - teamUrl = organisations[0].teams[0].url; - } - - if (!teamUrl) { + if (!navigationTeam) { return [ { href: '/inbox', text: t`Inbox`, }, + ...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole) + ? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }] + : []), { href: '/settings/profile', text: t`Settings`, @@ -56,6 +61,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps ]; } + const teamUrl = navigationTeam.url; + return [ { href: `/t/${teamUrl}/documents`, @@ -69,12 +76,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps href: '/inbox', text: t`Inbox`, }, + ...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole) + ? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }] + : []), { href: '/settings/profile', text: t`Settings`, }, ]; - }, [currentTeam, organisations]); + }, [currentTeam, currentOrganisation, organisations, t]); return ( diff --git a/apps/remix/app/components/general/filter-pill.tsx b/apps/remix/app/components/general/filter-pill.tsx index ae1d69f75..a786bd8f1 100644 --- a/apps/remix/app/components/general/filter-pill.tsx +++ b/apps/remix/app/components/general/filter-pill.tsx @@ -31,6 +31,8 @@ type FilterPillCommonProps = { enableSearch?: boolean; searchPlaceholder?: string; loading?: boolean; + /** Whether the selection can be removed. Defaults to true. */ + clearable?: boolean; testId?: string; }; @@ -61,7 +63,7 @@ export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps; * selections followed by a "+N more" chip. */ export const FilterPill = (props: FilterPillProps) => { - const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props; + const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, clearable = true, testId } = props; const [open, setOpen] = useState(false); @@ -84,7 +86,7 @@ export const FilterPill = (props: FilterPillProps) => { return; } - props.onChange(nextValue === props.value ? null : nextValue); + props.onChange(nextValue === props.value && clearable ? null : nextValue); setOpen(false); }; @@ -168,7 +170,7 @@ export const FilterPill = (props: FilterPillProps) => { ))} - {hasSelection && ( + {hasSelection && clearable && ( <> diff --git a/apps/remix/app/components/general/metric-card.tsx b/apps/remix/app/components/general/metric-card.tsx index 14ae66035..3659e62aa 100644 --- a/apps/remix/app/components/general/metric-card.tsx +++ b/apps/remix/app/components/general/metric-card.tsx @@ -7,9 +7,10 @@ export type CardMetricProps = { value?: string | number; className?: string; children?: React.ReactNode; + testId?: string; }; -export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => { +export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => { return (
{children || ( -

+

{typeof value === 'number' ? value.toLocaleString('en-US') : value}

)} diff --git a/apps/remix/app/components/general/org-menu-switcher.tsx b/apps/remix/app/components/general/org-menu-switcher.tsx index dfa52d964..842a801e0 100644 --- a/apps/remix/app/components/general/org-menu-switcher.tsx +++ b/apps/remix/app/components/general/org-menu-switcher.tsx @@ -6,9 +6,13 @@ import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/ import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { isAdmin } from '@documenso/lib/utils/is-admin'; -import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; +import { + canAccessOrganisationAnalytics, + canExecuteOrganisationAction, + formatOrganisationAnalyticsPath, +} from '@documenso/lib/utils/organisations'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog'; import { cn } from '@documenso/ui/lib/utils'; @@ -62,6 +66,13 @@ export const OrgMenuSwitcher = () => { const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole); + // Team analytics take precedence when in a team context, the team page links to organisation analytics. + const analyticsPath = canAccessTeamSettings + ? formatAnalyticsPath(currentTeam.url) + : currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole) + ? formatOrganisationAnalyticsPath(currentOrganisation.url) + : null; + // Use hovered org for teams display if available, // otherwise use current team's org if in a team, // finally fallback to selected org @@ -271,6 +282,14 @@ export const OrgMenuSwitcher = () => { + {analyticsPath && ( + + + Analytics + + + )} +
- +
+ {canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && ( + + )} + + +
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.analytics._index.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.analytics._index.tsx new file mode 100644 index 000000000..2521f7aae --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.analytics._index.tsx @@ -0,0 +1,179 @@ +import { getSession } from '@documenso/auth/server/lib/utils/get-session'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { formatAnalyticsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; +import { trpc } from '@documenso/trpc/react'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole } from '@prisma/client'; +import { UsersIcon } from 'lucide-react'; +import { redirect } from 'react-router'; + +import type { AnalyticsActivityRow } from '~/components/general/analytics/analytics-activity-table-card'; +import { AnalyticsActivityTableCard } from '~/components/general/analytics/analytics-activity-table-card'; +import { AnalyticsDocumentsOverTimeCard } from '~/components/general/analytics/analytics-documents-over-time-card'; +import { AnalyticsHydrateFallback } from '~/components/general/analytics/analytics-hydrate-fallback'; +import { AnalyticsNoActivityAlert } from '~/components/general/analytics/analytics-no-activity-alert'; +import { AnalyticsOverviewCards } from '~/components/general/analytics/analytics-overview-cards'; +import { AnalyticsPageHeader } from '~/components/general/analytics/analytics-page-header'; +import { AnalyticsStatusBreakdownCard } from '~/components/general/analytics/analytics-status-breakdown-card'; +import { AnalyticsTemplateUsageCard } from '~/components/general/analytics/analytics-template-usage-card'; +import { resolveBrowserTimezone, useAnalyticsRange } from '~/utils/analytics'; +import { appMetaTags } from '~/utils/meta'; + +import type { Route } from './+types/o.$orgUrl.analytics._index'; + +export function meta() { + return appMetaTags(msg`Analytics`); +} + +/** + * Organisation analytics are restricted to organisation admins (not managers), + * matching the tRPC procedures. + */ +export async function loader({ request, params }: Route.LoaderArgs) { + const session = await getSession(request); + + const organisation = await prisma.organisation.findFirst({ + where: { + ...buildOrganisationWhereQuery({ + organisationId: undefined, + userId: session.user.id, + roles: [OrganisationMemberRole.ADMIN], + }), + url: params.orgUrl, + }, + select: { id: true }, + }); + + if (!organisation) { + throw redirect(`/o/${params.orgUrl}`); + } + + return {}; +} + +/** + * The timezone is read from the browser so the analytics queries only run on the + * client, after hydration, with the correct day boundaries. + */ +export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) { + await serverLoader(); + + return { timezone: resolveBrowserTimezone() }; +} + +clientLoader.hydrate = true as const; + +export function HydrateFallback() { + return ; +} + +export default function OrganisationAnalyticsPage({ loaderData }: Route.ComponentProps) { + const organisation = useCurrentOrganisation(); + const { _ } = useLingui(); + const { timezone } = loaderData; + + const { value: range, rangeKey, setValue: setRange } = useAnalyticsRange(); + + // `from`/`to` are only present for custom ranges. + const queryInput = { organisationId: organisation.id, timezone, ...range }; + + const overviewQuery = trpc.organisation.analytics.getOverview.useQuery(queryInput); + const documentsOverTimeQuery = trpc.organisation.analytics.getDocumentsOverTime.useQuery(queryInput); + const statusBreakdownQuery = trpc.organisation.analytics.getStatusBreakdown.useQuery(queryInput); + const templateUsageQuery = trpc.organisation.analytics.getTemplateUsage.useQuery({ + ...queryInput, + limit: TEMPLATE_LIMIT, + }); + const teamActivityQuery = trpc.organisation.analytics.getTeamActivity.useQuery(queryInput); + + const hasNoActivity = + overviewQuery.isSuccess && + documentsOverTimeQuery.isSuccess && + overviewQuery.data.sent.current === 0 && + overviewQuery.data.sent.previous === 0 && + documentsOverTimeQuery.data.total === 0; + + const teamRows: AnalyticsActivityRow[] = (teamActivityQuery.data?.teams ?? []).map((team) => ({ + key: team.id, + avatar: { imageId: team.avatarImageId, fallback: team.name.slice(0, 1).toUpperCase() }, + title: team.name, + subtitle: `/t/${team.url}`, + sent: team.sent, + completed: team.completed, + pending: team.pending, + completionRate: team.completionRate, + lastActiveAt: team.lastActiveAt, + href: formatAnalyticsPath(team.url), + })); + + return ( +
+ + + {hasNoActivity && ( + setRange({ range: '12m' })} /> + )} + +
+ Teams, + testId: 'analytics-teams', + select: (data) => data.teams, + }} + /> + +
+ + +
+ + + template.team !== null && template.envelopeId !== null + ? `${formatTemplatesPath(template.team.url)}/${template.envelopeId}` + : null + } + renderTemplateMeta={(template) => template.team?.name} + /> + + Team activity} + description={Documents sent by each team in this period} + columnLabel={Team} + renderSummary={(count, activeCount) => ( + <> + · {activeCount} active this period + + )} + renderShowing={(visibleCount, totalCount) => ( + + Showing {visibleCount} of {totalCount} teams + + )} + emptyLabel={No teams} + searchPlaceholder={_(msg`Search teams`)} + noSearchResultsLabel={No teams match your search} + testIdPrefix="team" + /> +
+
+ ); +} + +const TEMPLATE_LIMIT = 5; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/analytics._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/analytics._index.tsx new file mode 100644 index 000000000..04914ae34 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/analytics._index.tsx @@ -0,0 +1,183 @@ +import { getSession } from '@documenso/auth/server/lib/utils/get-session'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; +import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { canExecuteTeamAction, formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Plural, Trans } from '@lingui/react/macro'; +import { ArrowRightIcon, UsersIcon } from 'lucide-react'; +import { Link, redirect } from 'react-router'; + +import type { AnalyticsActivityRow } from '~/components/general/analytics/analytics-activity-table-card'; +import { AnalyticsActivityTableCard } from '~/components/general/analytics/analytics-activity-table-card'; +import { AnalyticsDocumentsOverTimeCard } from '~/components/general/analytics/analytics-documents-over-time-card'; +import { AnalyticsHydrateFallback } from '~/components/general/analytics/analytics-hydrate-fallback'; +import { AnalyticsNoActivityAlert } from '~/components/general/analytics/analytics-no-activity-alert'; +import { AnalyticsOverviewCards } from '~/components/general/analytics/analytics-overview-cards'; +import { AnalyticsPageHeader } from '~/components/general/analytics/analytics-page-header'; +import { AnalyticsStatusBreakdownCard } from '~/components/general/analytics/analytics-status-breakdown-card'; +import { AnalyticsTemplateUsageCard } from '~/components/general/analytics/analytics-template-usage-card'; +import { useCurrentTeam } from '~/providers/team'; +import { resolveBrowserTimezone, useAnalyticsRange } from '~/utils/analytics'; +import { appMetaTags } from '~/utils/meta'; + +import type { Route } from './+types/analytics._index'; + +export function meta() { + return appMetaTags(msg`Analytics`); +} + +export async function loader({ request, params }: Route.LoaderArgs) { + const session = await getSession(request); + // `getTeamByUrl` throws when the user isn't a member; treat that like any other + // denial so the documents route renders its "Team not found" state instead of a 500. + const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl }).catch(() => null); + + if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) { + throw redirect(formatDocumentsPath(params.teamUrl)); + } + + return {}; +} + +/** + * The timezone is read from the browser so the analytics queries only run on the + * client, after hydration, with the correct day boundaries. + */ +export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) { + await serverLoader(); + + return { timezone: resolveBrowserTimezone() }; +} + +clientLoader.hydrate = true as const; + +export function HydrateFallback() { + return ; +} + +export default function TeamAnalyticsPage({ loaderData }: Route.ComponentProps) { + const team = useCurrentTeam(); + const { _ } = useLingui(); + const organisation = useCurrentOrganisation(); + const { timezone } = loaderData; + + const { value: range, rangeKey, setValue: setRange } = useAnalyticsRange(); + + // `from`/`to` are only present for custom ranges. + const queryInput = { teamId: team.id, timezone, ...range }; + + const overviewQuery = trpc.team.analytics.getOverview.useQuery(queryInput); + const documentsOverTimeQuery = trpc.team.analytics.getDocumentsOverTime.useQuery(queryInput); + const statusBreakdownQuery = trpc.team.analytics.getStatusBreakdown.useQuery(queryInput); + const templateUsageQuery = trpc.team.analytics.getTemplateUsage.useQuery({ ...queryInput, limit: TEMPLATE_LIMIT }); + const memberActivityQuery = trpc.team.analytics.getMemberActivity.useQuery(queryInput); + + const hasNoActivity = + overviewQuery.isSuccess && + documentsOverTimeQuery.isSuccess && + overviewQuery.data.sent.current === 0 && + overviewQuery.data.sent.previous === 0 && + documentsOverTimeQuery.data.total === 0; + + const templatesPath = formatTemplatesPath(team.url); + + const memberRows: AnalyticsActivityRow[] = (memberActivityQuery.data?.members ?? []).map((member) => ({ + key: member.userId, + avatar: { imageId: member.avatarImageId, fallback: formatMemberInitials(member.name, member.email) }, + title: member.name || member.email, + subtitle: member.name ? member.email : null, + sent: member.sent, + completed: member.completed, + pending: member.pending, + completionRate: member.completionRate, + lastActiveAt: member.lastActiveAt, + })); + + return ( +
+ + + View organisation analytics + + + + ) + } + /> + + {hasNoActivity && ( + setRange({ range: '12m' })} /> + )} + +
+ Members, + testId: 'analytics-members', + select: (data) => data.members, + }} + /> + +
+ + +
+ + + template.envelopeId !== null ? `${templatesPath}/${template.envelopeId}` : null + } + /> + + Member activity} + description={Documents sent by each member in this period} + columnLabel={Member} + renderSummary={(count, activeCount) => ( + <> + ·{' '} + {activeCount} active this period + + )} + renderShowing={(visibleCount, totalCount) => ( + + Showing {visibleCount} of {totalCount} members + + )} + emptyLabel={No members} + searchPlaceholder={_(msg`Search members`)} + noSearchResultsLabel={No members match your search} + testIdPrefix="member" + /> +
+
+ ); +} + +const TEMPLATE_LIMIT = 5; + +const formatMemberInitials = (name: string | null, email: string) => { + const initials = name ? extractInitials(name) : ''; + + return initials || email.slice(0, 1).toUpperCase(); +}; diff --git a/apps/remix/app/utils/analytics.ts b/apps/remix/app/utils/analytics.ts new file mode 100644 index 000000000..99f6dac4f --- /dev/null +++ b/apps/remix/app/utils/analytics.ts @@ -0,0 +1,193 @@ +import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { + ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK, + ZAnalyticsDateSchema, + ZTeamAnalyticsRangeSchema, +} from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { DateTime, IANAZone, Interval } from 'luxon'; +import { createParser, parseAsStringEnum, useQueryStates } from 'nuqs'; +import { useEffect, useMemo } from 'react'; + +/** + * The subset of a tRPC query result the analytics cards need. The queries live on + * the page (team or organisation) so the cards stay scope-agnostic. + */ +export type AnalyticsQueryResult = { + data: TData | undefined; + isLoading: boolean; + isError: boolean; + refetch: () => Promise; +}; + +export type TAnalyticsPresetRange = Exclude; + +/** + * A fully specified analytics range: either a preset, or a custom inclusive + * [from, to] window of yyyy-MM-dd calendar dates in the browser timezone. + */ +export type AnalyticsRangeValue = + | { range: TAnalyticsPresetRange; from?: undefined; to?: undefined } + | { range: 'custom'; from: string; to: string }; + +export const ANALYTICS_PRESET_RANGES: TAnalyticsPresetRange[] = ZTeamAnalyticsRangeSchema.options.filter( + (range): range is TAnalyticsPresetRange => range !== 'custom', +); + +export const ANALYTICS_RANGE_LABELS: Record = { + '7d': msg`Last 7 days`, + '30d': msg`Last 30 days`, + '90d': msg`Last 90 days`, + '12m': msg`Last 12 months`, + custom: msg`Custom range`, +}; + +export const ANALYTICS_NO_ACTIVITY_LABELS: Record = { + '7d': msg`No activity in the last 7 days.`, + '30d': msg`No activity in the last 30 days.`, + '90d': msg`No activity in the last 90 days.`, + '12m': msg`No activity in the last 12 months.`, + custom: msg`No activity in the selected range.`, +}; + +const DEFAULT_ANALYTICS_RANGE: TAnalyticsPresetRange = '30d'; + +/** + * The browser's IANA timezone, falling back to UTC when it cannot be resolved or + * is not a zone luxon recognises. Client-only. + */ +export const resolveBrowserTimezone = () => { + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return IANAZone.isValidZone(browserTimezone) ? browserTimezone : 'UTC'; +}; + +export const formatRelativeDate = (date: Date, locale: string) => { + return DateTime.fromJSDate(date).setLocale(locale).toRelative() ?? ''; +}; + +/** Parse a strict yyyy-MM-dd calendar date at local midnight, null when invalid. */ +export const parseAnalyticsDate = (value: string): DateTime | null => { + if (!ZAnalyticsDateSchema.safeParse(value).success) { + return null; + } + + const parsed = DateTime.fromISO(value); + + // Round-trip guard so overflowing dates such as 2023-02-30 are rejected. + if (!parsed.isValid || parsed.toISODate() !== value) { + return null; + } + + return parsed.startOf('day'); +}; + +/** Format a local `Date` (e.g. one picked in the calendar) as yyyy-MM-dd. */ +export const formatAnalyticsDate = (date: Date): string => { + return DateTime.fromJSDate(date).toISODate() ?? ''; +}; + +/** Human readable inclusive span, e.g. "Feb 1 – 29, 2024". */ +export const formatAnalyticsDateRange = (from: string, to: string, locale: string): string => { + const start = DateTime.fromISO(from); + const end = DateTime.fromISO(to); + + if (!start.isValid || !end.isValid) { + return `${from} – ${to}`; + } + + return Interval.fromDateTimes(start, end).toLocaleString(DateTime.DATE_MED, { locale }); +}; + +/** Number of calendar days in the inclusive [from, to] window, 0 when invalid. */ +export const getAnalyticsDateRangeDays = (from: string, to: string): number => { + const start = parseAnalyticsDate(from); + const end = parseAnalyticsDate(to); + + if (!start || !end || start > end) { + return 0; + } + + return Math.round(end.diff(start, 'days').days) + 1; +}; + +/** + * Validate a custom window client-side, mirroring the backend resolver: both + * dates present and valid, ordered, not in the future and within the maximum span. + */ +export const isValidAnalyticsCustomRange = (from: string | null, to: string | null): boolean => { + if (!from || !to) { + return false; + } + + const start = parseAnalyticsDate(from); + const end = parseAnalyticsDate(to); + + if (!start || !end || start > end) { + return false; + } + + const today = DateTime.local().startOf('day'); + + if (end > today) { + return false; + } + + return start >= today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK); +}; + +const parseAsAnalyticsDate = createParser({ + parse: (value) => (parseAnalyticsDate(value) ? value : null), + serialize: (value) => value, +}); + +export const analyticsRangeSearchParams = { + range: parseAsStringEnum(ZTeamAnalyticsRangeSchema.options).withDefault(DEFAULT_ANALYTICS_RANGE), + from: parseAsAnalyticsDate, + to: parseAsAnalyticsDate, +}; + +/** + * The analytics range held in the URL (`?range=`, plus `?from=&to=` for custom + * ranges). A custom range with missing or invalid bounds falls back to the default + * preset and the stray params are cleared from the URL. + */ +export const useAnalyticsRange = () => { + const [{ range, from, to }, setSearchParams] = useQueryStates(analyticsRangeSearchParams); + + const value = useMemo((): AnalyticsRangeValue => { + if (range !== 'custom') { + return { range }; + } + + if (from && to && isValidAnalyticsCustomRange(from, to)) { + return { range, from, to }; + } + + return { range: DEFAULT_ANALYTICS_RANGE }; + }, [range, from, to]); + + const hasInvalidCustomRange = range === 'custom' && value.range !== 'custom'; + + useEffect(() => { + if (hasInvalidCustomRange) { + void setSearchParams({ range: null, from: null, to: null }); + } + }, [hasInvalidCustomRange, setSearchParams]); + + const setValue = (next: AnalyticsRangeValue) => { + if (next.range === 'custom') { + void setSearchParams({ range: next.range, from: next.from, to: next.to }); + + return; + } + + void setSearchParams({ range: next.range, from: null, to: null }); + }; + + /** Changes whenever the effective window changes, including a custom span being adjusted. */ + const rangeKey = `${value.range}:${value.from ?? ''}:${value.to ?? ''}`; + + return { value, rangeKey, setValue }; +}; diff --git a/packages/app-tests/e2e/api/trpc/test-unauthorized-analytics-access.spec.ts b/packages/app-tests/e2e/api/trpc/test-unauthorized-analytics-access.spec.ts new file mode 100644 index 000000000..393b4da02 --- /dev/null +++ b/packages/app-tests/e2e/api/trpc/test-unauthorized-analytics-access.spec.ts @@ -0,0 +1,545 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { createTeam } from '@documenso/lib/server-only/team/create-team'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; +import { prisma } from '@documenso/prisma'; +import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/client'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; +import { seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { APIRequestContext, APIResponse } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { apiSignin } from '../../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +const nanoid = customAlphabet('1234567890abcdef', 10); + +test.describe.configure({ + mode: 'parallel', +}); + +const TEAM_ANALYTICS_PROCEDURES = [ + 'team.analytics.getOverview', + 'team.analytics.getDocumentsOverTime', + 'team.analytics.getStatusBreakdown', + 'team.analytics.getTemplateUsage', + 'team.analytics.getMemberActivity', +] as const; + +const ORGANISATION_ANALYTICS_PROCEDURES = [ + 'organisation.analytics.getOverview', + 'organisation.analytics.getDocumentsOverTime', + 'organisation.analytics.getStatusBreakdown', + 'organisation.analytics.getTemplateUsage', + 'organisation.analytics.getTeamActivity', +] as const; + +const seedScenario = async () => { + const suffix = nanoid(); + + const { user: owner, organisation, team: siblingTeam } = await seedUser(); + + const targetTeamUrl = `analytics-target-${suffix}`; + + await createTeam({ + userId: owner.id, + teamName: `Analytics Target Team ${suffix}`, + teamUrl: targetTeamUrl, + organisationId: organisation.id, + // Keeps plain organisation members out of the target team. + inheritMembers: false, + }); + + const targetTeam = await prisma.team.findFirstOrThrow({ where: { url: targetTeamUrl } }); + + const targetManagerName = `Analytics Target Manager ${suffix}`; + + const targetManager = await seedTeamMember({ + teamId: targetTeam.id, + name: targetManagerName, + role: TeamMemberRole.MANAGER, + }); + + const targetMember = await seedTeamMember({ teamId: targetTeam.id, role: TeamMemberRole.MEMBER }); + + const siblingTeamAdmin = await seedTeamMember({ teamId: siblingTeam.id, role: TeamMemberRole.ADMIN }); + + const [organisationMember, organisationManager] = await seedOrganisationMembers({ + organisationId: organisation.id, + members: [ + { organisationRole: OrganisationMemberRole.MEMBER }, + { organisationRole: OrganisationMemberRole.MANAGER }, + ], + }); + + const { user: outsider, team: outsiderTeam, organisation: outsiderOrganisation } = await seedUser(); + const { user: recipient } = await seedUser(); + + const template = await seedBlankTemplate(owner, targetTeam.id, { + createTemplateOptions: { title: `Analytics Target Template ${suffix}` }, + }); + + const document = await seedBlankDocument(targetManager, targetTeam.id, { + createDocumentOptions: { + title: `Analytics Target Document ${suffix}`, + status: DocumentStatus.PENDING, + templateId: mapSecondaryIdToTemplateId(template.secondaryId), + }, + }); + + await prisma.documentAuditLog.create({ + data: { + envelopeId: document.id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + createdAt: new Date(), + data: {}, + }, + }); + + await prisma.recipient.create({ + data: { + envelopeId: document.id, + email: recipient.email, + name: 'Analytics Recipient', + token: nanoid(), + }, + }); + + const markers = { + teamName: targetTeam.name, + templateTitle: template.title, + managerName: targetManagerName, + managerEmail: targetManager.email, + }; + + return { + owner, + organisation, + targetTeam, + targetManager, + targetMember, + siblingTeamAdmin, + organisationMember, + organisationManager, + outsider, + outsiderTeam, + outsiderOrganisation, + recipient, + markers: Object.values(markers), + namedMarkers: markers, + }; +}; + +type Scenario = Awaited>; + +type DeniedCaller = { + name: string; + caller: (scenario: Scenario) => { email: string }; +}; + +const TEAM_DENIED_CALLERS: DeniedCaller[] = [ + { name: 'a user from another organisation', caller: (s) => s.outsider }, + { name: 'a recipient of a team document who is not a member', caller: (s) => s.recipient }, + { name: 'an organisation member who is not in the team', caller: (s) => s.organisationMember }, + { name: 'an admin of a sibling team', caller: (s) => s.siblingTeamAdmin }, + { name: 'a team member below manager', caller: (s) => s.targetMember }, +]; + +const ORGANISATION_DENIED_CALLERS: DeniedCaller[] = [ + { name: 'a user from another organisation', caller: (s) => s.outsider }, + { name: 'a recipient of an organisation document', caller: (s) => s.recipient }, + { name: 'an organisation member', caller: (s) => s.organisationMember }, + { name: 'an organisation manager', caller: (s) => s.organisationManager }, + { name: 'a team admin who is an organisation member', caller: (s) => s.siblingTeamAdmin }, + { name: 'a team manager who is an organisation member', caller: (s) => s.targetManager }, +]; + +test.describe('Team Analytics API - Adversarial: Access', () => { + test('should reject unauthenticated requests on every procedure', async ({ request }) => { + const { targetTeam, markers } = await seedScenario(); + + for (const procedure of TEAM_ANALYTICS_PROCEDURES) { + const res = await trpcQuery(request, procedure, { teamId: targetTeam.id }); + + expectDenied(res, markers); + } + }); + + for (const { name, caller } of TEAM_DENIED_CALLERS) { + test(`should reject ${name} on every procedure`, async ({ page }) => { + const scenario = await seedScenario(); + + await apiSignin({ page, email: caller(scenario).email }); + + for (const procedure of TEAM_ANALYTICS_PROCEDURES) { + const res = await trpcQuery(page.context().request, procedure, { teamId: scenario.targetTeam.id }); + + expectDenied(res, scenario.markers); + } + }); + } + + test('should allow team admins and managers (control)', async ({ page }) => { + const { owner, targetManager, organisationManager, targetTeam, namedMarkers } = await seedScenario(); + + for (const caller of [owner, targetManager, organisationManager]) { + await apiSignin({ page, email: caller.email }); + + const bodies: string[] = []; + + for (const procedure of TEAM_ANALYTICS_PROCEDURES) { + const res = await trpcQuery(page.context().request, procedure, { teamId: targetTeam.id }); + + expectAllowed(res); + bodies.push(res.body); + } + + const combined = bodies.join('\n'); + + expect(combined).toContain(namedMarkers.templateTitle); + expect(combined).toContain(namedMarkers.managerName); + expect(combined).toContain(namedMarkers.managerEmail); + } + }); +}); + +test.describe('Team Analytics API - Adversarial: Tampering', () => { + test('should ignore identity fields injected into the input', async ({ page }) => { + const { owner, outsider, targetTeam, markers } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + for (const procedure of TEAM_ANALYTICS_PROCEDURES) { + const res = await trpcQuery(page.context().request, procedure, { + teamId: targetTeam.id, + userId: owner.id, + userEmail: owner.email, + }); + + expectDenied(res, markers); + } + }); + + test('should not grant access through the x-team-id header', async ({ page }) => { + const { outsider, outsiderTeam, targetTeam, markers } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + for (const procedure of TEAM_ANALYTICS_PROCEDURES) { + const targetHeader = await trpcQuery( + page.context().request, + procedure, + { teamId: targetTeam.id }, + { 'x-team-id': targetTeam.id.toString() }, + ); + + expectDenied(targetHeader, markers); + + const ownHeader = await trpcQuery( + page.context().request, + procedure, + { teamId: targetTeam.id }, + { 'x-team-id': outsiderTeam.id.toString() }, + ); + + expectDenied(ownHeader, markers); + } + }); + + test('should reject target team calls batched with an allowed call', async ({ page }) => { + const { outsider, outsiderTeam, targetTeam, markers } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + const { response, body, results } = await trpcBatchQuery(page.context().request, [ + { procedure: 'team.analytics.getOverview', input: { teamId: outsiderTeam.id } }, + ...TEAM_ANALYTICS_PROCEDURES.map((procedure) => ({ procedure, input: { teamId: targetTeam.id } })), + ]); + + expect(response.status()).toBe(207); + expect(results).toHaveLength(TEAM_ANALYTICS_PROCEDURES.length + 1); + + const [allowed, ...denied] = results; + + expect(allowed.result).toBeDefined(); + expect(allowed.error).toBeUndefined(); + + for (const item of denied) { + expect(item.result).toBeUndefined(); + expect(item.error?.json.data.httpStatus).toBe(401); + expect(item.error?.json.data.code).toBe('UNAUTHORIZED'); + } + + expectNoMarkers(body, markers); + }); + + test('should reject API tokens, even one scoped to the target team', async ({ request }) => { + const { owner, targetTeam, markers } = await seedScenario(); + + const { token } = await createApiToken({ + userId: owner.id, + teamId: targetTeam.id, + tokenName: 'analytics-adversarial', + expiresIn: null, + }); + + for (const procedure of TEAM_ANALYTICS_PROCEDURES) { + for (const authorization of [`Bearer ${token}`, token]) { + const res = await trpcQuery(request, procedure, { teamId: targetTeam.id }, { Authorization: authorization }); + + expectDenied(res, markers); + } + } + }); + + test('should not reveal whether a team exists', async ({ page }) => { + const { outsider, targetTeam } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + for (const procedure of TEAM_ANALYTICS_PROCEDURES) { + const existing = await trpcQuery(page.context().request, procedure, { teamId: targetTeam.id }); + const missing = await trpcQuery(page.context().request, procedure, { teamId: 2_147_483_647 }); + + expect(existing.response.status()).toBe(401); + expect(missing.response.status()).toBe(401); + expect(errorMessageOf(missing)).toBe(errorMessageOf(existing)); + } + }); +}); + +test.describe('Organisation Analytics API - Adversarial: Access', () => { + test('should reject unauthenticated requests on every procedure', async ({ request }) => { + const { organisation, markers } = await seedScenario(); + + for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) { + const res = await trpcQuery(request, procedure, { organisationId: organisation.id }); + + expectDenied(res, markers); + } + }); + + for (const { name, caller } of ORGANISATION_DENIED_CALLERS) { + test(`should reject ${name} on every procedure`, async ({ page }) => { + const scenario = await seedScenario(); + + await apiSignin({ page, email: caller(scenario).email }); + + for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) { + const res = await trpcQuery(page.context().request, procedure, { + organisationId: scenario.organisation.id, + }); + + expectDenied(res, scenario.markers); + } + }); + } + + test('should allow organisation admins (control)', async ({ page }) => { + const { owner, organisation, namedMarkers } = await seedScenario(); + + await apiSignin({ page, email: owner.email }); + + const bodies: string[] = []; + + for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) { + const res = await trpcQuery(page.context().request, procedure, { organisationId: organisation.id }); + + expectAllowed(res); + bodies.push(res.body); + } + + const combined = bodies.join('\n'); + + expect(combined).toContain(namedMarkers.teamName); + expect(combined).toContain(namedMarkers.templateTitle); + }); +}); + +test.describe('Organisation Analytics API - Adversarial: Tampering', () => { + test('should ignore identity fields injected into the input', async ({ page }) => { + const { owner, outsider, organisation, markers } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) { + const res = await trpcQuery(page.context().request, procedure, { + organisationId: organisation.id, + userId: owner.id, + userEmail: owner.email, + }); + + expectDenied(res, markers); + } + }); + + test('should not grant access through the x-team-id header', async ({ page }) => { + const { organisationMember, organisation, targetTeam, markers } = await seedScenario(); + + await apiSignin({ page, email: organisationMember.email }); + + for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) { + const res = await trpcQuery( + page.context().request, + procedure, + { organisationId: organisation.id }, + { 'x-team-id': targetTeam.id.toString() }, + ); + + expectDenied(res, markers); + } + }); + + test('should reject target organisation calls batched with an allowed call', async ({ page }) => { + const { outsider, outsiderOrganisation, organisation, markers } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + const { response, body, results } = await trpcBatchQuery(page.context().request, [ + { procedure: 'organisation.analytics.getOverview', input: { organisationId: outsiderOrganisation.id } }, + ...ORGANISATION_ANALYTICS_PROCEDURES.map((procedure) => ({ + procedure, + input: { organisationId: organisation.id }, + })), + ]); + + expect(response.status()).toBe(207); + expect(results).toHaveLength(ORGANISATION_ANALYTICS_PROCEDURES.length + 1); + + const [allowed, ...denied] = results; + + expect(allowed.result).toBeDefined(); + expect(allowed.error).toBeUndefined(); + + for (const item of denied) { + expect(item.result).toBeUndefined(); + expect(item.error?.json.data.httpStatus).toBe(401); + expect(item.error?.json.data.code).toBe('UNAUTHORIZED'); + } + + expectNoMarkers(body, markers); + }); + + test('should reject API tokens, even one belonging to an organisation admin', async ({ request }) => { + const { owner, organisation, targetTeam, markers } = await seedScenario(); + + const { token } = await createApiToken({ + userId: owner.id, + teamId: targetTeam.id, + tokenName: 'analytics-adversarial', + expiresIn: null, + }); + + for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) { + for (const authorization of [`Bearer ${token}`, token]) { + const res = await trpcQuery( + request, + procedure, + { organisationId: organisation.id }, + { Authorization: authorization }, + ); + + expectDenied(res, markers); + } + } + }); + + test('should not reveal whether an organisation exists', async ({ page }) => { + const { outsider, organisation } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) { + const existing = await trpcQuery(page.context().request, procedure, { organisationId: organisation.id }); + const missing = await trpcQuery(page.context().request, procedure, { + organisationId: `org_does_not_exist_${nanoid()}`, + }); + + expect(existing.response.status()).toBe(401); + expect(missing.response.status()).toBe(401); + expect(errorMessageOf(missing)).toBe(errorMessageOf(existing)); + } + }); +}); + +type TrpcResponseItem = { + result?: { data: { json: unknown } }; + error?: { json: { message: string; data: { code: string; httpStatus: number } } }; +}; + +type TrpcResult = { + response: APIResponse; + body: string; + json: TrpcResponseItem | null; +}; + +const DEFAULT_ANALYTICS_INPUT = { range: '30d', timezone: 'UTC' }; + +const trpcQuery = async ( + request: APIRequestContext, + procedure: string, + input: Record, + headers: Record = {}, +): Promise => { + const inputParam = encodeURIComponent(JSON.stringify({ json: { ...DEFAULT_ANALYTICS_INPUT, ...input } })); + + const response = await request.get(`${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`, { headers }); + + const body = await response.text(); + + return { response, body, json: parseJson(body) }; +}; + +const trpcBatchQuery = async ( + request: APIRequestContext, + calls: Array<{ procedure: string; input: Record }>, +) => { + const procedures = calls.map((call) => call.procedure).join(','); + + const input = Object.fromEntries( + calls.map((call, index) => [index, { json: { ...DEFAULT_ANALYTICS_INPUT, ...call.input } }]), + ); + + const response = await request.get( + `${WEBAPP_BASE_URL}/api/trpc/${procedures}?batch=1&input=${encodeURIComponent(JSON.stringify(input))}`, + ); + + const body = await response.text(); + + return { response, body, results: parseJson(body) ?? [] }; +}; + +const parseJson = (body: string): T | null => { + try { + return JSON.parse(body); + } catch { + return null; + } +}; + +const errorMessageOf = (res: TrpcResult) => res.json?.error?.json.message; + +const expectNoMarkers = (body: string, markers: string[]) => { + for (const marker of markers) { + expect(body).not.toContain(marker); + } +}; + +const expectDenied = (res: TrpcResult, markers: string[]) => { + expect(res.response.status()).toBe(401); + expect(res.json?.result).toBeUndefined(); + expect(res.json?.error?.json.data.code).toBe('UNAUTHORIZED'); + + expectNoMarkers(res.body, markers); +}; + +const expectAllowed = (res: TrpcResult) => { + expect(res.response.status()).toBe(200); + expect(res.json?.result?.data.json).toBeDefined(); +}; diff --git a/packages/app-tests/e2e/organisations/organisation-analytics.spec.ts b/packages/app-tests/e2e/organisations/organisation-analytics.spec.ts new file mode 100644 index 000000000..353f021c4 --- /dev/null +++ b/packages/app-tests/e2e/organisations/organisation-analytics.spec.ts @@ -0,0 +1,289 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createTeam } from '@documenso/lib/server-only/team/create-team'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { prisma } from '@documenso/prisma'; +import type { User } from '@documenso/prisma/client'; +import { DocumentStatus, DocumentVisibility, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/client'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DateTime } from 'luxon'; +import { customAlphabet } from 'nanoid'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +const nanoid = customAlphabet('1234567890abcdef', 10); + +type AnalyticsRange = '7d' | '30d' | '90d' | '12m'; + +/** + * Timestamps are relative to now and kept at least a day away from every window + * boundary (7, 30, 60 and 90 days) so the assertions hold regardless of timezone. + */ +const daysAgo = (days: number) => DateTime.now().minus({ days }).toJSDate(); + +test.describe.configure({ mode: 'parallel' }); + +test('[ORG ANALYTICS]: admin sees aggregated numbers across teams', async ({ page }) => { + const { owner, teamA, teamB, organisation } = await seedOrganisationWithTwoTeams(); + + // Team A: 4 sent, 3 completed. + await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) }); + await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) }); + await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(4) }); + await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.PENDING, sentAt: daysAgo(5) }); + + // Team B: 3 sent, 2 completed. Organisation admins see every document, so the + // ADMIN-visibility one counts too. + await seedAnalyticsDocument({ owner, teamId: teamB.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(6) }); + await seedAnalyticsDocument({ owner, teamId: teamB.id, status: DocumentStatus.PENDING, sentAt: daysAgo(8) }); + await seedAnalyticsDocument({ + owner, + teamId: teamB.id, + status: DocumentStatus.COMPLETED, + visibility: DocumentVisibility.ADMIN, + sentAt: daysAgo(9), + }); + + await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(organisation.url) }); + await waitForAnalytics(page); + + // Overview: 7 sent, 5 completed (71%), both teams active. + await expect(page.getByTestId('analytics-sent')).toHaveText('7'); + await expect(page.getByTestId('analytics-completion-rate')).toHaveText('71%'); + await expect(page.getByTestId('analytics-teams')).toHaveText('2/2'); + + await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('7 total'); + await expect(page.getByTestId('analytics-status-completed')).toHaveText('5'); + await expect(page.getByTestId('analytics-status-pending')).toHaveText('2'); + + // Team activity: sorted by sent desc, so team A (4) comes before team B (3). + const rows = page.getByTestId('analytics-team-row'); + + await expect(page.getByTestId('analytics-team-summary')).toContainText('2 teams'); + await expect(page.getByTestId('analytics-team-summary')).toContainText('2 active'); + await expect(rows).toHaveCount(2); + + await expect(rows.nth(0)).toContainText(teamA.name); + await expect(rows.nth(0)).toContainText(`/t/${teamA.url}`); + await expectTeamRow(rows.nth(0), { sent: '4', completed: '3', pending: '1', completionRate: '75%' }); + + await expect(rows.nth(1)).toContainText(teamB.name); + await expect(rows.nth(1)).toContainText(`/t/${teamB.url}`); + await expectTeamRow(rows.nth(1), { sent: '3', completed: '2', pending: '1', completionRate: '67%' }); +}); + +test("[ORG ANALYTICS]: clicking a team row opens that team's analytics", async ({ page }) => { + const { owner, teamA, organisation } = await seedOrganisationWithTwoTeams(); + + // Team A has activity so it is sorted first. + await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) }); + + await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(organisation.url) }); + await waitForAnalytics(page); + + const firstRow = page.getByTestId('analytics-team-row').first(); + + // The title is a real link to the team's analytics. + await expect(firstRow.getByRole('link', { name: teamA.name })).toHaveAttribute('href', `/t/${teamA.url}/analytics`); + + // Clicking a non-link cell navigates via the row click handler. + await firstRow.getByTestId('analytics-team-sent').click(); + await page.waitForURL(new RegExp(`/t/${teamA.url}/analytics(?:\\?.*)?$`)); +}); + +test('[ORG ANALYTICS]: only organisation admins can access', async ({ page }) => { + const { team, owner, organisation } = await seedTeam(); + + // Team members seeded this way are organisation MEMBERs. + const member = await seedTeamMember({ + teamId: team.id, + name: 'Analytics Member', + role: TeamMemberRole.ADMIN, + }); + + const [manager] = await seedOrganisationMembers({ + members: [{ name: 'Analytics Manager', organisationRole: OrganisationMemberRole.MANAGER }], + organisationId: organisation.id, + }); + + const organisationHomePattern = new RegExp(`/o/${organisation.url}(?:\\?.*)?$`); + + // Unauthenticated: the page redirects to sign in and the API rejects the call. + await page.goto(analyticsPath(organisation.url)); + await page.waitForURL(/\/signin(?:\?.*)?$/); + + const unauthenticatedResponse = await requestOverview(page, organisation.id); + expect(unauthenticatedResponse.status()).toBe(401); + + // Organisation member: redirected to the organisation home, API rejects the call. + await apiSignin({ page, email: member.email, redirectPath: analyticsPath(organisation.url) }); + await page.waitForURL(organisationHomePattern); + + const memberResponse = await requestOverview(page, organisation.id); + expect(memberResponse.status()).toBe(401); + + await apiSignout({ page }); + + // Organisation manager: the gate is ADMIN only, so managers are rejected too. + await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(organisation.url) }); + await page.waitForURL(organisationHomePattern); + + const managerResponse = await requestOverview(page, organisation.id); + expect(managerResponse.status()).toBe(401); + + await apiSignout({ page }); + + // Non-member denial: a user outside the organisation is redirected away and rejected by the API. + const { user: nonMember } = await seedUser(); + + await apiSignin({ page, email: nonMember.email, redirectPath: analyticsPath(organisation.url) }); + await page.waitForURL(organisationHomePattern); + + const nonMemberResponse = await requestOverview(page, organisation.id); + expect(nonMemberResponse.status()).toBe(401); + + await apiSignout({ page }); + + // Organisation admin: the menu switcher links to the page and the API responds. + await apiSignin({ page, email: owner.email, redirectPath: `/o/${organisation.url}` }); + + await page.getByTestId('menu-switcher').click(); + + // Outside a team context the single "Analytics" item points at organisation analytics. + const analyticsMenuItem = page.getByRole('menuitem', { name: 'Analytics', exact: true }); + + await expect(analyticsMenuItem).toHaveAttribute('href', `/o/${organisation.url}/analytics`); + await analyticsMenuItem.click(); + + await page.waitForURL(new RegExp(`/o/${organisation.url}/analytics(?:\\?.*)?$`)); + await waitForAnalytics(page); + + // Inside a team context the item points at team analytics, and the team page links onwards. + await page.goto(`/t/${team.url}/analytics`); + await waitForAnalytics(page); + + await page.getByTestId('menu-switcher').click(); + + await expect(page.getByRole('menuitem', { name: 'Analytics', exact: true })).toHaveAttribute( + 'href', + `/t/${team.url}/analytics`, + ); + + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'View organisation analytics' }).click(); + await page.waitForURL(new RegExp(`/o/${organisation.url}/analytics(?:\\?.*)?$`)); + + const adminResponse = await requestOverview(page, organisation.id); + expect(adminResponse.ok()).toBe(true); + expect(await adminResponse.text()).toContain('"completionRate"'); +}); + +/** + * Seed an organisation with two teams. The owner is an organisation ADMIN and, + * through `inheritMembers`, an admin of both teams. + */ +const seedOrganisationWithTwoTeams = async () => { + const { owner, team: teamA, organisation } = await seedTeam(); + + const teamBUrl = `analytics-team-b-${nanoid()}`; + + await createTeam({ + userId: owner.id, + teamName: 'Analytics Team B', + teamUrl: teamBUrl, + organisationId: organisation.id, + inheritMembers: true, + }); + + const teamB = await prisma.team.findFirstOrThrow({ + where: { + url: teamBUrl, + }, + }); + + return { owner, teamA, teamB, organisation }; +}; + +/** + * Seed a team document with an optional DOCUMENT_SENT audit log. + */ +const seedAnalyticsDocument = async ({ + owner, + teamId, + status, + visibility = DocumentVisibility.EVERYONE, + sentAt, +}: { + owner: User; + teamId: number; + status: DocumentStatus; + visibility?: DocumentVisibility; + sentAt?: Date; +}) => { + const envelope = await seedBlankDocument(owner, teamId, { + createDocumentOptions: { + status, + visibility, + ...(status === DocumentStatus.COMPLETED && sentAt ? { completedAt: sentAt } : {}), + }, + }); + + if (sentAt) { + await prisma.documentAuditLog.createMany({ + data: [ + { + envelopeId: envelope.id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + createdAt: sentAt, + data: {}, + }, + ], + }); + } + + return envelope; +}; + +const analyticsPath = (organisationUrl: string, range?: AnalyticsRange) => { + const path = `/o/${organisationUrl}/analytics`; + + return range ? `${path}?range=${range}` : path; +}; + +/** + * The analytics queries only run after hydration, which can be slow on a cold dev + * server, so wait for the hydrate fallback to be replaced before asserting values. + */ +const waitForAnalytics = async (page: Page) => { + await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('analytics-loading')).toHaveCount(0, { timeout: 30_000 }); +}; + +const expectTeamRow = async ( + row: Locator, + expected: { sent: string; completed: string; pending: string; completionRate: string }, +) => { + await expect(row.getByTestId('analytics-team-sent')).toHaveText(expected.sent); + await expect(row.getByTestId('analytics-team-completed')).toHaveText(expected.completed); + await expect(row.getByTestId('analytics-team-pending')).toHaveText(expected.pending); + await expect(row.getByTestId('analytics-team-completion-rate')).toHaveText(expected.completionRate); +}; + +const requestAnalytics = async (page: Page, procedure: 'getOverview' | 'getTeamActivity', organisationId: string) => { + const input = encodeURIComponent(JSON.stringify({ json: { organisationId, range: '30d', timezone: 'UTC' } })); + + return await page + .context() + .request.get(`${WEBAPP_BASE_URL}/api/trpc/organisation.analytics.${procedure}?input=${input}`); +}; + +const requestOverview = async (page: Page, organisationId: string) => { + return await requestAnalytics(page, 'getOverview', organisationId); +}; diff --git a/packages/app-tests/e2e/teams/team-analytics.spec.ts b/packages/app-tests/e2e/teams/team-analytics.spec.ts new file mode 100644 index 000000000..f3256a51e --- /dev/null +++ b/packages/app-tests/e2e/teams/team-analytics.spec.ts @@ -0,0 +1,690 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; +import { prisma } from '@documenso/prisma'; +import type { User } from '@documenso/prisma/client'; +import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DateTime } from 'luxon'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +type AnalyticsRange = '7d' | '30d' | '90d' | '12m'; + +/** + * Timestamps are relative to now and kept at least a day away from every window + * boundary (7, 30, 60 and 90 days) so the assertions hold regardless of timezone. + */ +const daysAgo = (days: number) => DateTime.now().minus({ days }).toJSDate(); + +/** + * The same day as `daysAgo` as a yyyy-MM-dd calendar date in the host timezone, + * which is also the browser timezone the page sends with custom ranges. + */ +const daysAgoDate = (days: number) => DateTime.now().minus({ days }).toFormat('yyyy-MM-dd'); + +test.describe.configure({ mode: 'parallel' }); + +test('[ANALYTICS]: admin sees overview numbers for the last 30 days', async ({ page }) => { + const { team, owner } = await seedTeam(); + + // Current window: 5 sent, 3 completed. + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(3) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(4) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(12) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(15) }); + + // Previous window: 2 sent, 1 completed. + const resentDocument = await seedAnalyticsDocument({ + owner, + teamId: team.id, + status: DocumentStatus.COMPLETED, + sentAt: daysAgo(40), + }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(45) }); + + // First send: a re-send inside the current window leaves the document counted once, in the previous window. + await prisma.documentAuditLog.create({ + data: { + envelopeId: resentDocument.id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + createdAt: daysAgo(20), + data: {}, + }, + }); + + // Soft delete: a deleted document sent in the window is excluded, so none of the numbers below change. + await seedAnalyticsDocument({ + owner, + teamId: team.id, + status: DocumentStatus.COMPLETED, + sentAt: daysAgo(6), + deletedAt: new Date(), + }); + + // Never sent. Every document above without `createdAt` was created today. + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.DRAFT }); + + // Never sent, created just after local midnight so bucketing in the wrong timezone would shift their day. + const createdDaysAgo = [3, 3, 5]; + + for (const days of createdDaysAgo) { + await seedAnalyticsDocument({ + owner, + teamId: team.id, + status: DocumentStatus.DRAFT, + createdAt: DateTime.now().minus({ days }).startOf('day').plus({ minutes: 30 }).toJSDate(), + }); + } + + await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) }); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-range')).toContainText('Last 30 days'); + + await expect(page.getByTestId('analytics-sent')).toHaveText('5'); + await expect(page.getByTestId('analytics-sent-delta')).toHaveText('+150%'); + await expect(page.getByTestId('analytics-completion-rate')).toHaveText('60%'); + await expect(page.getByTestId('analytics-completion-rate-delta')).toHaveText('+10%'); + await expect(page.getByTestId('analytics-members')).toHaveText('1/1'); + + // Day buckets: documents land on their creation day in the request timezone and empty days are zero-filled. + const daily = await requestDocumentsOverTime(page, team.id, { range: '30d' }); + + expect(daily.points).toHaveLength(30); + expect(daily.points).toContainEqual({ date: daysAgoDate(3), count: 2 }); + expect(daily.points).toContainEqual({ date: daysAgoDate(4), count: 0 }); + expect(daily.points).toContainEqual({ date: daysAgoDate(5), count: 1 }); + + // Month buckets: keyed by month start, the last one sums this month's documents (8 created today, deleted excluded). + const monthStart = DateTime.now().startOf('month'); + const createdThisMonth = 8 + createdDaysAgo.filter((days) => DateTime.now().minus({ days }) >= monthStart).length; + const monthly = await requestDocumentsOverTime(page, team.id, { range: '12m' }); + + expect(monthly.points).toHaveLength(12); + expect(monthly.points.at(-1)).toEqual({ date: monthStart.toFormat('yyyy-MM-dd'), count: createdThisMonth }); + + // Switching to 90 days pulls the previous window into the current one. + await selectRange(page, 'Last 90 days'); + await expectRangeParam(page, '90d'); + await expect(page.getByTestId('analytics-sent')).toHaveText('7'); + + // Loading a range directly from the URL works too. The 7 day previous window + // (7-14 days ago) only contains the document sent 12 days ago. + await page.goto(analyticsPath(team.url, '7d')); + await waitForAnalytics(page); + await expect(page.getByTestId('analytics-sent')).toHaveText('3'); + await expect(page.getByTestId('analytics-sent-delta')).toHaveText('+200%'); +}); + +test('[ANALYTICS]: a custom date range scopes activity to the selected days', async ({ page }) => { + const { team, owner } = await seedTeam(); + + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(5) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(20) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(40) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(50) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(200) }); + + // A 16 day window loaded from the URL only contains the document sent 20 days + // ago. The equally sized previous window (41-26 days ago) contains the one sent + // 40 days ago, so the delta is flat. + const windowFrom = daysAgoDate(25); + const windowTo = daysAgoDate(10); + + await apiSignin({ page, email: owner.email, redirectPath: customAnalyticsPath(team.url, windowFrom, windowTo) }); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-sent')).toHaveText('1'); + await expect(page.getByTestId('analytics-sent-delta')).toHaveText('0%'); + await expect(page.getByTestId('analytics-documents-over-time')).toContainText('Daily'); + + // The trigger shows the formatted window, e.g. "Aug 29 – Sep 13, 2026". + const rangeTrigger = page.getByTestId('analytics-range'); + + await expect(rangeTrigger).toContainText(String(DateTime.fromISO(windowTo).year)); + + // Picking a new window in the calendar replaces the current one on apply. + const pickedFrom = daysAgoDate(6); + const pickedTo = daysAgoDate(2); + + await rangeTrigger.click(); + await page.getByTestId('analytics-range-custom').click(); + + await clickCalendarDay(page, pickedFrom); + await clickCalendarDay(page, pickedTo); + await page.getByTestId('analytics-range-apply').click(); + + await expectCustomRangeParams(page, pickedFrom, pickedTo); + await expect(page.getByTestId('analytics-range-calendar')).toHaveCount(0); + await expect(page.getByTestId('analytics-sent')).toHaveText('2'); + await expect(page.getByTestId('analytics-sent-delta')).toHaveText('New'); + + // Windows longer than 92 days are bucketed by month. + await page.goto(customAnalyticsPath(team.url, daysAgoDate(120), daysAgoDate(1))); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-documents-over-time')).toContainText('Monthly'); + await expect(page.getByTestId('analytics-sent')).toHaveText('5'); + + // An invalid window (from after to) falls back to the default preset and the + // stray params are cleared from the URL. + await page.goto(customAnalyticsPath(team.url, daysAgoDate(5), daysAgoDate(10))); + await waitForAnalytics(page); + + await expect.poll(() => page.url()).not.toContain('range=custom'); + await expect.poll(() => new URL(page.url()).searchParams.has('from')).toBe(false); + await expect(page.getByTestId('analytics-sent')).toHaveText('3'); + + // A window starting more than 12 months ago is rejected the same way. + await page.goto(customAnalyticsPath(team.url, daysAgoDate(400), daysAgoDate(380))); + await waitForAnalytics(page); + + await expect.poll(() => page.url()).not.toContain('range=custom'); + + // The API rejects an invalid custom window outright (the resolver rules + // themselves are unit tested). + const invalidResponse = await requestOverview(page, team.id, { + range: 'custom', + from: daysAgoDate(5), + to: daysAgoDate(10), + }); + + expect(invalidResponse.status()).toBe(400); +}); + +test('[ANALYTICS]: a manager only sees documents within their visibility scope', async ({ page }) => { + const { team, owner } = await seedTeam(); + const manager = await seedTeamMember({ + teamId: team.id, + name: 'Analytics Manager', + role: TeamMemberRole.MANAGER, + }); + + for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED, DocumentStatus.PENDING]) { + await seedAnalyticsDocument({ + owner, + teamId: team.id, + status, + visibility: DocumentVisibility.EVERYONE, + sentAt: daysAgo(3), + }); + } + + for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED]) { + await seedAnalyticsDocument({ + owner, + teamId: team.id, + status, + visibility: DocumentVisibility.ADMIN, + sentAt: daysAgo(4), + }); + } + + // Owner clause: an ADMIN-only document the manager owns is in their scope, unlike the owner's ADMIN-only ones. + await seedAnalyticsDocument({ + owner: manager, + teamId: team.id, + status: DocumentStatus.COMPLETED, + visibility: DocumentVisibility.ADMIN, + sentAt: daysAgo(3), + }); + + await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(team.url) }); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-sent')).toHaveText('4'); + await expect(page.getByTestId('analytics-status-completed')).toHaveText('3'); + await expect(page.getByTestId('analytics-status-pending')).toHaveText('1'); + await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('4 total'); + await expect(page.getByTestId('analytics-members')).toHaveText('2/2'); + + // An ADMIN-only document still counts for the manager when they are a recipient. + await seedAnalyticsDocument({ + owner, + teamId: team.id, + status: DocumentStatus.PENDING, + visibility: DocumentVisibility.ADMIN, + sentAt: daysAgo(5), + recipientEmail: manager.email, + }); + + await page.reload(); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-sent')).toHaveText('5'); + await expect(page.getByTestId('analytics-status-completed')).toHaveText('3'); + await expect(page.getByTestId('analytics-status-pending')).toHaveText('2'); + await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('5 total'); + + await apiSignout({ page }); + await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) }); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-sent')).toHaveText('7'); + await expect(page.getByTestId('analytics-status-completed')).toHaveText('5'); + await expect(page.getByTestId('analytics-status-pending')).toHaveText('2'); + await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('7 total'); +}); + +test('[ANALYTICS]: template usage ranks templates by documents created from them', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const popularTemplate = await seedBlankTemplate(owner, team.id, { + createTemplateOptions: { title: 'Analytics Popular Template' }, + }); + const otherTemplate = await seedBlankTemplate(owner, team.id, { + createTemplateOptions: { title: 'Analytics Other Template' }, + }); + const deletedTemplate = await seedBlankTemplate(owner, team.id, { + createTemplateOptions: { title: 'Analytics Deleted Template', deletedAt: new Date() }, + }); + + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(2) }); + await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) }); + + // Distinct counts so the order does not depend on the tie-breaker. + for (const [template, count] of [ + [popularTemplate, 3], + [otherTemplate, 2], + [deletedTemplate, 1], + ] as const) { + for (let index = 0; index < count; index += 1) { + await seedAnalyticsDocument({ + owner, + teamId: team.id, + status: DocumentStatus.PENDING, + sentAt: daysAgo(2), + templateSecondaryId: template.secondaryId, + }); + } + } + + await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) }); + await waitForAnalytics(page); + + const rows = page.getByTestId('analytics-template-row'); + + await expect(rows).toHaveCount(3); + + await expect(rows.nth(0)).toContainText('Analytics Popular Template'); + await expect(rows.nth(0)).toContainText('3 uses'); + await expect(rows.nth(0).getByRole('link', { name: 'Analytics Popular Template' })).toHaveAttribute( + 'href', + `/t/${team.url}/templates/${popularTemplate.id}`, + ); + + await expect(rows.nth(1)).toContainText('Analytics Other Template'); + await expect(rows.nth(1)).toContainText('2 uses'); + + await expect(rows.nth(2)).toContainText('Unavailable template'); + await expect(rows.nth(2)).toContainText('1 use'); + await expect(rows.nth(2)).not.toContainText('Analytics Deleted Template'); + await expect(rows.nth(2).getByRole('link')).toHaveCount(0); +}); + +test('[ANALYTICS]: member activity respects visibility per member', async ({ page }) => { + // `seedTeam` hardcodes the owner name, so seed the owner directly to control it. + const { user: jane, team } = await seedUser({ name: 'Jane Analytics' }); + + const manager = await seedTeamMember({ + teamId: team.id, + name: 'Analytics Manager', + role: TeamMemberRole.MANAGER, + }); + + await seedTeamMember({ + teamId: team.id, + name: 'Analytics Member', + role: TeamMemberRole.MEMBER, + }); + + // Jane: 3 EVERYONE (2 completed, 1 pending) + 2 ADMIN-only (both completed). + for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED, DocumentStatus.PENDING]) { + await seedAnalyticsDocument({ + owner: jane, + teamId: team.id, + status, + visibility: DocumentVisibility.EVERYONE, + sentAt: daysAgo(3), + }); + } + + for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED]) { + await seedAnalyticsDocument({ + owner: jane, + teamId: team.id, + status, + visibility: DocumentVisibility.ADMIN, + sentAt: daysAgo(4), + }); + } + + // Manager: 2 EVERYONE (1 completed, 1 pending). + for (const status of [DocumentStatus.COMPLETED, DocumentStatus.PENDING]) { + await seedAnalyticsDocument({ + owner: manager, + teamId: team.id, + status, + visibility: DocumentVisibility.EVERYONE, + sentAt: daysAgo(5), + }); + } + + const rows = page.getByTestId('analytics-member-row'); + + // Admin sees everything. + await apiSignin({ page, email: jane.email, redirectPath: analyticsPath(team.url) }); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-member-summary')).toContainText('3 members'); + await expect(page.getByTestId('analytics-member-summary')).toContainText('2 active'); + await expect(rows).toHaveCount(3); + + await expect(rows.nth(0)).toContainText('Jane Analytics'); + await expectMemberRow(rows.nth(0), { sent: '5', completed: '4', pending: '1', completionRate: '80%' }); + + await expect(rows.nth(1)).toContainText('Analytics Manager'); + await expectMemberRow(rows.nth(1), { sent: '2', completed: '1', pending: '1', completionRate: '50%' }); + + await expect(rows.nth(2)).toContainText('Analytics Member'); + await expectMemberRow(rows.nth(2), { sent: '0', completed: '0', pending: '0', completionRate: '—' }); + + // Search filters the table case-insensitively. + const search = page.getByTestId('analytics-member-search'); + + await search.fill('MANAGER'); + await expect(rows).toHaveCount(1); + await expect(rows.nth(0)).toContainText('Analytics Manager'); + + await search.fill(''); + await expect(rows).toHaveCount(3); + + // Manager: Jane's two ADMIN-only documents are excluded from her row. + await apiSignout({ page }); + await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(team.url) }); + await waitForAnalytics(page); + + await expect(rows).toHaveCount(3); + + await expect(rows.nth(0)).toContainText('Jane Analytics'); + await expectMemberRow(rows.nth(0), { sent: '3', completed: '2', pending: '1', completionRate: '67%' }); +}); + +test('[ANALYTICS]: member activity previews 8 members and can show all', async ({ page }) => { + // 8 organisation members inherited into the team + the owner = 9 team members. + const { team, owner } = await seedTeam({ createTeamMembers: 8 }); + + const rows = page.getByTestId('analytics-member-row'); + + await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) }); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-member-summary')).toContainText('9 members'); + await expect(rows).toHaveCount(8); + + await page.getByTestId('analytics-member-show-all').click(); + + await expect(rows).toHaveCount(9); + await expect(page.getByTestId('analytics-member-show-all')).toHaveCount(0); +}); + +test('[ANALYTICS]: members and unauthenticated users cannot access analytics', async ({ page }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ + teamId: team.id, + name: 'Analytics Member', + role: TeamMemberRole.MEMBER, + }); + + const documentsPathPattern = new RegExp(`/t/${team.url}/documents(?:\\?.*)?$`); + + // Unauthenticated: the page redirects to sign in and the API rejects the call. + await page.goto(analyticsPath(team.url)); + await page.waitForURL(/\/signin(?:\?.*)?$/); + + const unauthenticatedResponse = await requestOverview(page, team.id); + expect(unauthenticatedResponse.status()).toBe(401); + + // Member: no nav link, redirected away from the page, API rejects the calls. + await apiSignin({ page, email: member.email, redirectPath: `/t/${team.url}/documents` }); + await page.waitForURL(documentsPathPattern); + + await page.getByTestId('menu-switcher').click(); + + // Anchor on an item every user sees, so the absence check can't pass on an unopened menu. + await expect(page.getByRole('menuitem', { name: 'Inbox', exact: true })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Analytics', exact: true })).toHaveCount(0); + await page.keyboard.press('Escape'); + + await page.goto(analyticsPath(team.url)); + await page.waitForURL(documentsPathPattern); + + const memberOverviewResponse = await requestOverview(page, team.id); + expect(memberOverviewResponse.status()).toBe(401); + + const memberActivityResponse = await requestMemberActivity(page, team.id); + expect(memberActivityResponse.status()).toBe(401); + + await apiSignout({ page }); + + // Non-member denial: a user outside the team's organisation gets "Team not found", not a crash, + // and is rejected by the API. + const { user: nonMember } = await seedUser(); + + await apiSignin({ page, email: nonMember.email }); + + await page.goto(analyticsPath(team.url)); + await page.waitForURL(documentsPathPattern); + await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible(); + + const nonMemberResponse = await requestOverview(page, team.id); + expect(nonMemberResponse.status()).toBe(401); + + await apiSignout({ page }); + + // Admin: the menu switcher item leads to the analytics page. + await apiSignin({ page, email: owner.email, redirectPath: `/t/${team.url}/documents` }); + + await page.getByTestId('menu-switcher').click(); + await page.getByRole('menuitem', { name: 'Analytics', exact: true }).click(); + await page.waitForURL(new RegExp(`/t/${team.url}/analytics(?:\\?.*)?$`)); + await waitForAnalytics(page); + + const adminResponse = await requestOverview(page, team.id); + expect(adminResponse.ok()).toBe(true); +}); + +test('[ANALYTICS]: an empty team renders empty states without errors', async ({ page }) => { + const { team, owner } = await seedTeam(); + + await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) }); + await waitForAnalytics(page); + + await expect(page.getByTestId('analytics-sent')).toHaveText('0'); + await expect(page.getByTestId('analytics-sent-delta')).toHaveCount(0); + await expect(page.getByTestId('analytics-completion-rate')).toHaveText('—'); + await expect(page.getByTestId('analytics-completion-rate-delta')).toHaveCount(0); + await expect(page.getByTestId('analytics-members')).toHaveText('0/1'); + + await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('0 total'); + await expect(page.getByTestId('analytics-status-completed')).toHaveCount(0); + await expect(page.getByTestId('analytics-template-row')).toHaveCount(0); + + // The zero-row checks above would also pass if a card errored, so assert that none did. + await expect(page.getByTestId('analytics-error')).toHaveCount(0); +}); + +/** + * Seed a team document with an optional DOCUMENT_SENT audit log, recipient and + * source template. + */ +const seedAnalyticsDocument = async ({ + owner, + teamId, + status, + visibility = DocumentVisibility.EVERYONE, + sentAt, + recipientEmail, + templateSecondaryId, + createdAt, + deletedAt, +}: { + owner: User; + teamId: number; + status: DocumentStatus; + visibility?: DocumentVisibility; + sentAt?: Date; + recipientEmail?: string; + templateSecondaryId?: string; + createdAt?: Date; + deletedAt?: Date; +}) => { + const envelope = await seedBlankDocument(owner, teamId, { + createDocumentOptions: { + status, + visibility, + createdAt, + deletedAt, + ...(status === DocumentStatus.COMPLETED && sentAt ? { completedAt: sentAt } : {}), + ...(templateSecondaryId ? { templateId: mapSecondaryIdToTemplateId(templateSecondaryId) } : {}), + }, + }); + + if (sentAt) { + await prisma.documentAuditLog.createMany({ + data: [ + { + envelopeId: envelope.id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + createdAt: sentAt, + data: {}, + }, + ], + }); + } + + if (recipientEmail) { + await prisma.recipient.create({ + data: { + envelopeId: envelope.id, + email: recipientEmail, + name: 'Analytics Recipient', + token: Math.random().toString().slice(2, 12), + }, + }); + } + + return envelope; +}; + +const analyticsPath = (teamUrl: string, range?: AnalyticsRange) => { + const path = `/t/${teamUrl}/analytics`; + + return range ? `${path}?range=${range}` : path; +}; + +const customAnalyticsPath = (teamUrl: string, from: string, to: string) => { + return `${analyticsPath(teamUrl)}?range=custom&from=${from}&to=${to}`; +}; + +/** + * The analytics queries only run after hydration, which can be slow on a cold dev + * server, so wait for the hydrate fallback to be replaced before asserting values. + */ +const waitForAnalytics = async (page: Page) => { + await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('analytics-loading')).toHaveCount(0, { timeout: 30_000 }); +}; + +const selectRange = async (page: Page, label: string) => { + await page.getByTestId('analytics-range').click(); + await page.getByRole('option', { name: label, exact: true }).click(); +}; + +const expectRangeParam = async (page: Page, range: AnalyticsRange) => { + await expect.poll(() => new URL(page.url()).searchParams.get('range')).toBe(range); +}; + +const expectCustomRangeParams = async (page: Page, from: string, to: string) => { + await expect + .poll(() => { + const { searchParams } = new URL(page.url()); + + return { range: searchParams.get('range'), from: searchParams.get('from'), to: searchParams.get('to') }; + }) + .toEqual({ range: 'custom', from, to }); +}; + +/** + * Click a yyyy-MM-dd day in the open range calendar. Each visible month renders a + * grid labelled by its caption (e.g. "September 2026"), so the day button is + * scoped to the matching grid to avoid hitting the same day number in the other month. + */ +const clickCalendarDay = async (page: Page, date: string) => { + const day = DateTime.fromISO(date).setLocale('en'); + + const monthGrid = page + .getByTestId('analytics-range-calendar') + .getByRole('grid', { name: day.toFormat('LLLL yyyy'), exact: true }); + + await monthGrid.getByRole('gridcell', { name: String(day.day), exact: true }).click(); +}; + +const expectMemberRow = async ( + row: Locator, + expected: { sent: string; completed: string; pending: string; completionRate: string }, +) => { + await expect(row.getByTestId('analytics-member-sent')).toHaveText(expected.sent); + await expect(row.getByTestId('analytics-member-completed')).toHaveText(expected.completed); + await expect(row.getByTestId('analytics-member-pending')).toHaveText(expected.pending); + await expect(row.getByTestId('analytics-member-completion-rate')).toHaveText(expected.completionRate); +}; + +type AnalyticsRequestRange = { range: AnalyticsRange } | { range: 'custom'; from: string; to: string }; + +const requestAnalytics = async ( + page: Page, + procedure: 'getOverview' | 'getMemberActivity' | 'getDocumentsOverTime', + teamId: number, + range: AnalyticsRequestRange = { range: '30d' }, + timezone = 'UTC', +) => { + const input = encodeURIComponent(JSON.stringify({ json: { teamId, timezone, ...range } })); + + return await page.context().request.get(`${WEBAPP_BASE_URL}/api/trpc/team.analytics.${procedure}?input=${input}`); +}; + +const requestOverview = async (page: Page, teamId: number, range?: AnalyticsRequestRange) => { + return await requestAnalytics(page, 'getOverview', teamId, range); +}; + +const requestMemberActivity = async (page: Page, teamId: number) => { + return await requestAnalytics(page, 'getMemberActivity', teamId); +}; + +/** + * Fetch documents over time in the host timezone, so bucket dates line up with + * `daysAgoDate` and the `createdAt` of documents seeded with `daysAgo`. + */ +const requestDocumentsOverTime = async (page: Page, teamId: number, range: AnalyticsRequestRange) => { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const response = await requestAnalytics(page, 'getDocumentsOverTime', teamId, range, timezone); + + expect(response.ok()).toBe(true); + + const body: { result: { data: { json: Pick } } } = + await response.json(); + + return body.result.data.json; +}; diff --git a/packages/lib/server-only/organisation/get-organisation-analytics-documents-over-time.ts b/packages/lib/server-only/organisation/get-organisation-analytics-documents-over-time.ts new file mode 100644 index 000000000..76256f7d3 --- /dev/null +++ b/packages/lib/server-only/organisation/get-organisation-analytics-documents-over-time.ts @@ -0,0 +1,81 @@ +import { kyselyPrisma, sql } from '@documenso/prisma'; +import type { + TGetOrganisationAnalyticsDocumentsOverTimeRequest, + TGetOrganisationAnalyticsDocumentsOverTimeResponse, +} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types'; +import { DateTime } from 'luxon'; + +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { toTeamAnalyticsCount } from '../team/get-team-analytics-scope'; +import { getOrganisationAnalyticsScope } from './get-organisation-analytics-scope'; + +export type GetOrganisationAnalyticsDocumentsOverTimeOptions = TGetOrganisationAnalyticsDocumentsOverTimeRequest & { + userId: number; +}; + +/** + * Number of documents created across the organisation per day (or per month for + * `12m` and long custom ranges) inside the requested window, zero-filled so every + * bucket is present. With month buckets the first and last points may cover only + * part of a month. + */ +export const getOrganisationAnalyticsDocumentsOverTime = async ({ + userId, + organisationId, + range, + from, + to, + timezone, +}: GetOrganisationAnalyticsDocumentsOverTimeOptions): Promise => { + const scope = await getOrganisationAnalyticsScope({ organisationId, userId }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end, bucket } = resolvedRange; + + // `createdAt` is a naive TIMESTAMP holding UTC, so it must be tagged as UTC before + // shifting into the request timezone; otherwise Postgres treats it as local time. + const bucketDate = sql`to_char( + date_trunc(${bucket}, (${sql.ref('Envelope.createdAt')} at time zone 'UTC') at time zone ${timezone}), + 'YYYY-MM-DD' + )`; + + const rows = await kyselyPrisma.$kysely + .selectFrom('Envelope') + .select(({ fn }) => [bucketDate.as('date'), fn.countAll().as('count')]) + .where((eb) => scope.applyEnvelopeScope(eb)) + .where('Envelope.createdAt', '>=', start) + .where('Envelope.createdAt', '<', end) + .groupBy('date') + .orderBy('date') + .execute(); + + const countsByDate = new Map(rows.map((row) => [row.date, toTeamAnalyticsCount(row.count)])); + + const points: TGetOrganisationAnalyticsDocumentsOverTimeResponse['points'] = []; + + const endTime = DateTime.fromJSDate(end, { zone: timezone }); + + // Align the cursor to the bucket boundary so it produces the same keys as + // `date_trunc` above. A custom range starting mid-month with month buckets would + // otherwise miss its first (partial) month entirely. + let cursor = DateTime.fromJSDate(start, { zone: timezone }).startOf(bucket); + + while (cursor < endTime) { + const date = cursor.toFormat('yyyy-MM-dd'); + + points.push({ + date, + count: countsByDate.get(date) ?? 0, + }); + + cursor = cursor.plus(bucket === 'month' ? { months: 1 } : { days: 1 }); + } + + const total = points.reduce((sum, point) => sum + point.count, 0); + + return { + range: resolvedRange, + total, + points, + }; +}; diff --git a/packages/lib/server-only/organisation/get-organisation-analytics-overview.ts b/packages/lib/server-only/organisation/get-organisation-analytics-overview.ts new file mode 100644 index 000000000..f7cef3b75 --- /dev/null +++ b/packages/lib/server-only/organisation/get-organisation-analytics-overview.ts @@ -0,0 +1,117 @@ +import { kyselyPrisma, prisma, sql } from '@documenso/prisma'; +import type { + TGetOrganisationAnalyticsOverviewRequest, + TGetOrganisationAnalyticsOverviewResponse, +} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types'; +import { DocumentStatus } from '@prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { calculateTeamAnalyticsCompletionRate, toTeamAnalyticsCount } from '../team/get-team-analytics-scope'; +import { getOrganisationAnalyticsScope } from './get-organisation-analytics-scope'; + +export type GetOrganisationAnalyticsOverviewOptions = TGetOrganisationAnalyticsOverviewRequest & { + userId: number; +}; + +/** + * Headline organisation analytics: documents sent, completion rate and team + * activity for the requested window and the window immediately before it. + * + * A document counts as "sent" in a window when its first DOCUMENT_SENT audit log + * falls inside that window. + */ +export const getOrganisationAnalyticsOverview = async ({ + userId, + organisationId, + range, + from, + to, + timezone, +}: GetOrganisationAnalyticsOverviewOptions): Promise => { + const scope = await getOrganisationAnalyticsScope({ organisationId, userId }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end, previousStart, previousEnd } = resolvedRange; + + const teamCount = await prisma.team.count({ + where: { + organisationId: scope.organisationId, + }, + }); + + const row = await kyselyPrisma.$kysely + .with('scopedEnvelopes', (db) => + db + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.status', 'Envelope.teamId']) + .where((eb) => scope.applyEnvelopeScope(eb)), + ) + .with('firstSent', (db) => + db + .selectFrom('DocumentAuditLog') + .innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'DocumentAuditLog.envelopeId') + .select(({ fn }) => ['DocumentAuditLog.envelopeId', fn.min('DocumentAuditLog.createdAt').as('sentAt')]) + .where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT) + .where('DocumentAuditLog.createdAt', '>=', previousStart) + .where('DocumentAuditLog.createdAt', '<', end) + .groupBy('DocumentAuditLog.envelopeId'), + ) + .selectFrom('firstSent') + .innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'firstSent.envelopeId') + .select(({ fn, eb }) => { + const inCurrentWindow = eb.and([eb('firstSent.sentAt', '>=', start), eb('firstSent.sentAt', '<', end)]); + + const inPreviousWindow = eb.and([ + eb('firstSent.sentAt', '>=', previousStart), + eb('firstSent.sentAt', '<', previousEnd), + ]); + + const isCompleted = eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.COMPLETED)); + + return [ + fn.countAll().filterWhere(inCurrentWindow).as('sentCurrent'), + fn.countAll().filterWhere(inPreviousWindow).as('sentPrevious'), + fn + .countAll() + .filterWhere(eb.and([inCurrentWindow, isCompleted])) + .as('completedCurrent'), + fn + .countAll() + .filterWhere(eb.and([inPreviousWindow, isCompleted])) + .as('completedPrevious'), + fn.count('scopedEnvelopes.teamId').distinct().filterWhere(inCurrentWindow).as('activeTeams'), + ]; + }) + .executeTakeFirst(); + + if (!row) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Analytics overview query returned no result', + }); + } + + const sentCurrent = toTeamAnalyticsCount(row.sentCurrent); + const sentPrevious = toTeamAnalyticsCount(row.sentPrevious); + const completedCurrent = toTeamAnalyticsCount(row.completedCurrent); + const completedPrevious = toTeamAnalyticsCount(row.completedPrevious); + + return { + range: resolvedRange, + sent: { + current: sentCurrent, + previous: sentPrevious, + }, + completionRate: { + completed: completedCurrent, + sent: sentCurrent, + rate: calculateTeamAnalyticsCompletionRate({ completed: completedCurrent, sent: sentCurrent }), + previousRate: calculateTeamAnalyticsCompletionRate({ completed: completedPrevious, sent: sentPrevious }), + }, + teams: { + total: teamCount, + active: toTeamAnalyticsCount(row.activeTeams), + }, + }; +}; diff --git a/packages/lib/server-only/organisation/get-organisation-analytics-scope.ts b/packages/lib/server-only/organisation/get-organisation-analytics-scope.ts new file mode 100644 index 000000000..620e312a1 --- /dev/null +++ b/packages/lib/server-only/organisation/get-organisation-analytics-scope.ts @@ -0,0 +1,101 @@ +import { prisma, sql } from '@documenso/prisma'; +import type { Prisma } from '@prisma/client'; +import { EnvelopeType, OrganisationMemberRole } from '@prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { buildOrganisationWhereQuery } from '../../utils/organisations'; +import type { ApplyEnvelopeScope } from '../team/get-team-analytics-scope'; + +export type GetOrganisationAnalyticsScopeOptions = { + organisationId: string; + userId: number; +}; + +/** + * Authorise the caller for organisation analytics (organisation ADMIN only) and + * build the envelope scope used by every organisation analytics procedure. + * + * The organisation ADMIN role authorises organisation-wide visibility. The internal + * ADMIN group is attached to every team by `createTeam` and cannot be detached, so + * every non-deleted document across the organisation's teams is in scope and no + * per-document visibility filtering is applied. + */ +export const getOrganisationAnalyticsScope = async ({ + organisationId, + userId, +}: GetOrganisationAnalyticsScopeOptions) => { + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ + organisationId, + userId, + roles: [OrganisationMemberRole.ADMIN], + }), + select: { + id: true, + }, + }); + + if (!organisation) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You are not allowed to view analytics for this organisation', + }); + } + + const envelopeWhere: Prisma.EnvelopeWhereInput = { + team: { + organisationId: organisation.id, + }, + type: EnvelopeType.DOCUMENT, + deletedAt: null, + }; + + /** + * Kysely predicate equivalent of `envelopeWhere`, for use in `Envelope` queries + * that need aggregates Prisma cannot express. + */ + const applyEnvelopeScope: ApplyEnvelopeScope = (eb) => + eb.and([ + eb('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)), + eb('Envelope.deletedAt', 'is', null), + eb( + 'Envelope.teamId', + 'in', + eb.selectFrom('Team').select('Team.id').where('Team.organisationId', '=', organisation.id), + ), + ]); + + return { + organisationId: organisation.id, + userId, + envelopeWhere, + applyEnvelopeScope, + }; +}; + +export type OrganisationAnalyticsScope = Awaited>; + +export type OrganisationAnalyticsTeam = { + id: number; + name: string; + url: string; + avatarImageId: string | null; +}; + +/** + * Every team in the organisation, sorted by name. + */ +export const getOrganisationAnalyticsTeams = async (organisationId: string): Promise => { + const teams = await prisma.team.findMany({ + where: { + organisationId, + }, + select: { + id: true, + name: true, + url: true, + avatarImageId: true, + }, + }); + + return teams.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); +}; diff --git a/packages/lib/server-only/organisation/get-organisation-analytics-status-breakdown.ts b/packages/lib/server-only/organisation/get-organisation-analytics-status-breakdown.ts new file mode 100644 index 000000000..63db94395 --- /dev/null +++ b/packages/lib/server-only/organisation/get-organisation-analytics-status-breakdown.ts @@ -0,0 +1,69 @@ +import { prisma } from '@documenso/prisma'; +import type { + TGetOrganisationAnalyticsStatusBreakdownRequest, + TGetOrganisationAnalyticsStatusBreakdownResponse, +} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types'; +import { DocumentStatus } from '@prisma/client'; + +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { getOrganisationAnalyticsScope } from './get-organisation-analytics-scope'; + +export type GetOrganisationAnalyticsStatusBreakdownOptions = TGetOrganisationAnalyticsStatusBreakdownRequest & { + userId: number; +}; + +/** + * Current status of every document created across the organisation inside the + * requested window. + */ +export const getOrganisationAnalyticsStatusBreakdown = async ({ + userId, + organisationId, + range, + from, + to, + timezone, +}: GetOrganisationAnalyticsStatusBreakdownOptions): Promise => { + const scope = await getOrganisationAnalyticsScope({ organisationId, userId }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end } = resolvedRange; + + const groups = await prisma.envelope.groupBy({ + by: ['status'], + where: { + ...scope.envelopeWhere, + createdAt: { + gte: start, + lt: end, + }, + }, + _count: { + _all: true, + }, + }); + + const counts: Record = { + [DocumentStatus.DRAFT]: 0, + [DocumentStatus.PENDING]: 0, + [DocumentStatus.COMPLETED]: 0, + [DocumentStatus.REJECTED]: 0, + [DocumentStatus.CANCELLED]: 0, + }; + + for (const group of groups) { + counts[group.status] = group._count._all; + } + + const total = Object.values(counts).reduce((sum, count) => sum + count, 0); + + return { + range: resolvedRange, + total, + draft: counts[DocumentStatus.DRAFT], + pending: counts[DocumentStatus.PENDING], + completed: counts[DocumentStatus.COMPLETED], + rejected: counts[DocumentStatus.REJECTED], + cancelled: counts[DocumentStatus.CANCELLED], + }; +}; diff --git a/packages/lib/server-only/organisation/get-organisation-analytics-team-activity.ts b/packages/lib/server-only/organisation/get-organisation-analytics-team-activity.ts new file mode 100644 index 000000000..07493fe43 --- /dev/null +++ b/packages/lib/server-only/organisation/get-organisation-analytics-team-activity.ts @@ -0,0 +1,117 @@ +import { kyselyPrisma, sql } from '@documenso/prisma'; +import type { + TGetOrganisationAnalyticsTeamActivityRequest, + TGetOrganisationAnalyticsTeamActivityResponse, +} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types'; +import { DocumentStatus } from '@prisma/client'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { calculateTeamAnalyticsCompletionRate, toTeamAnalyticsCount } from '../team/get-team-analytics-scope'; +import { getOrganisationAnalyticsScope, getOrganisationAnalyticsTeams } from './get-organisation-analytics-scope'; + +export type GetOrganisationAnalyticsTeamActivityOptions = TGetOrganisationAnalyticsTeamActivityRequest & { + userId: number; +}; + +/** + * Per-team activity for every team in the organisation. Teams with no activity + * are included with zeros. + * + * A document counts as "sent" by a team when it belongs to the team and has a + * DOCUMENT_SENT audit log inside the window. The app logs DOCUMENT_SENT once per + * document, so the earliest log within the window is used as its sent time (and + * drives "last active"). + */ +export const getOrganisationAnalyticsTeamActivity = async ({ + userId, + organisationId, + range, + from, + to, + timezone, +}: GetOrganisationAnalyticsTeamActivityOptions): Promise => { + const scope = await getOrganisationAnalyticsScope({ organisationId, userId }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end } = resolvedRange; + + const teams = await getOrganisationAnalyticsTeams(scope.organisationId); + + if (teams.length === 0) { + return { + range: resolvedRange, + teams: [], + }; + } + + const rows = await kyselyPrisma.$kysely + .with('scopedEnvelopes', (db) => + db + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.status', 'Envelope.teamId']) + .where((eb) => scope.applyEnvelopeScope(eb)), + ) + .with('firstSent', (db) => + db + .selectFrom('DocumentAuditLog') + .innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'DocumentAuditLog.envelopeId') + .select(({ fn }) => ['DocumentAuditLog.envelopeId', fn.min('DocumentAuditLog.createdAt').as('sentAt')]) + .where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT) + .where('DocumentAuditLog.createdAt', '>=', start) + .where('DocumentAuditLog.createdAt', '<', end) + .groupBy('DocumentAuditLog.envelopeId'), + ) + .selectFrom('firstSent') + .innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'firstSent.envelopeId') + .select(({ fn, eb }) => [ + 'scopedEnvelopes.teamId', + fn.countAll().as('sent'), + fn + .countAll() + .filterWhere(eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.COMPLETED))) + .as('completed'), + fn + .countAll() + .filterWhere(eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.PENDING))) + .as('pending'), + fn.max('firstSent.sentAt').as('lastActiveAt'), + ]) + .groupBy('scopedEnvelopes.teamId') + .execute(); + + const activityByTeamId = new Map(rows.map((row) => [row.teamId, row])); + + const teamActivity = teams.map((team) => { + const activity = activityByTeamId.get(team.id); + + const sent = activity ? toTeamAnalyticsCount(activity.sent) : 0; + const completed = activity ? toTeamAnalyticsCount(activity.completed) : 0; + const pending = activity ? toTeamAnalyticsCount(activity.pending) : 0; + + return { + id: team.id, + name: team.name, + url: team.url, + avatarImageId: team.avatarImageId, + sent, + completed, + pending, + completionRate: calculateTeamAnalyticsCompletionRate({ completed, sent }), + lastActiveAt: activity?.lastActiveAt ?? null, + }; + }); + + teamActivity.sort((a, b) => { + if (a.sent !== b.sent) { + return b.sent - a.sent; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + + return { + range: resolvedRange, + teams: teamActivity, + }; +}; diff --git a/packages/lib/server-only/organisation/get-organisation-analytics-template-usage.ts b/packages/lib/server-only/organisation/get-organisation-analytics-template-usage.ts new file mode 100644 index 000000000..17b4c8f73 --- /dev/null +++ b/packages/lib/server-only/organisation/get-organisation-analytics-template-usage.ts @@ -0,0 +1,127 @@ +import { prisma } from '@documenso/prisma'; +import type { + TGetOrganisationAnalyticsTemplateUsageRequest, + TGetOrganisationAnalyticsTemplateUsageResponse, +} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types'; +import { EnvelopeType } from '@prisma/client'; + +import { mapTemplateIdToSecondaryId } from '../../utils/envelope'; +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { getOrganisationAnalyticsScope } from './get-organisation-analytics-scope'; + +export type GetOrganisationAnalyticsTemplateUsageOptions = TGetOrganisationAnalyticsTemplateUsageRequest & { + userId: number; +}; + +/** + * Templates across the organisation ranked by how many documents were created from + * them inside the requested window. Template metadata (including the owning team) + * is null when the template has been deleted. + */ +export const getOrganisationAnalyticsTemplateUsage = async ({ + userId, + organisationId, + range, + from, + to, + timezone, + limit, +}: GetOrganisationAnalyticsTemplateUsageOptions): Promise => { + const scope = await getOrganisationAnalyticsScope({ organisationId, userId }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end } = resolvedRange; + + const groups = await prisma.envelope.groupBy({ + by: ['templateId'], + where: { + ...scope.envelopeWhere, + templateId: { + not: null, + }, + createdAt: { + gte: start, + lt: end, + }, + }, + _count: { + _all: true, + }, + orderBy: [ + { + _count: { + templateId: 'desc', + }, + }, + { + templateId: 'asc', + }, + ], + take: limit, + }); + + const usage = groups.flatMap((group) => { + if (group.templateId === null) { + return []; + } + + return [ + { + templateId: group.templateId, + count: group._count._all, + }, + ]; + }); + + if (usage.length === 0) { + return { + range: resolvedRange, + templates: [], + }; + } + + const templates = await prisma.envelope.findMany({ + where: { + type: EnvelopeType.TEMPLATE, + deletedAt: null, + team: { + organisationId: scope.organisationId, + }, + secondaryId: { + in: usage.map(({ templateId }) => mapTemplateIdToSecondaryId(templateId)), + }, + }, + select: { + id: true, + secondaryId: true, + title: true, + updatedAt: true, + team: { + select: { + id: true, + name: true, + url: true, + avatarImageId: true, + }, + }, + }, + }); + + const templatesBySecondaryId = new Map(templates.map((template) => [template.secondaryId, template])); + + return { + range: resolvedRange, + templates: usage.map(({ templateId, count }) => { + const template = templatesBySecondaryId.get(mapTemplateIdToSecondaryId(templateId)); + + return { + id: templateId, + envelopeId: template?.id ?? null, + title: template?.title ?? null, + updatedAt: template?.updatedAt ?? null, + team: template?.team ?? null, + count, + }; + }), + }; +}; diff --git a/packages/lib/server-only/team/get-team-analytics-documents-over-time.ts b/packages/lib/server-only/team/get-team-analytics-documents-over-time.ts new file mode 100644 index 000000000..0cf52f7ca --- /dev/null +++ b/packages/lib/server-only/team/get-team-analytics-documents-over-time.ts @@ -0,0 +1,81 @@ +import { kyselyPrisma, sql } from '@documenso/prisma'; +import type { + TGetTeamAnalyticsDocumentsOverTimeRequest, + TGetTeamAnalyticsDocumentsOverTimeResponse, +} from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { DateTime } from 'luxon'; + +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { getTeamAnalyticsScope, toTeamAnalyticsCount } from './get-team-analytics-scope'; + +export type GetTeamAnalyticsDocumentsOverTimeOptions = TGetTeamAnalyticsDocumentsOverTimeRequest & { + userId: number; + userEmail: string; +}; + +/** + * Number of visible documents created per day (or per month for `12m` and long custom + * ranges) inside the requested window, zero-filled so every bucket is present. With + * month buckets the first and last points may cover only part of a month. + */ +export const getTeamAnalyticsDocumentsOverTime = async ({ + userId, + userEmail, + teamId, + range, + from, + to, + timezone, +}: GetTeamAnalyticsDocumentsOverTimeOptions): Promise => { + const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end, bucket } = resolvedRange; + + // `createdAt` is a naive TIMESTAMP holding UTC, so it must be tagged as UTC before + // shifting into the request timezone; otherwise Postgres treats it as local time. + const bucketDate = sql`to_char( + date_trunc(${bucket}, (${sql.ref('Envelope.createdAt')} at time zone 'UTC') at time zone ${timezone}), + 'YYYY-MM-DD' + )`; + + const rows = await kyselyPrisma.$kysely + .selectFrom('Envelope') + .select(({ fn }) => [bucketDate.as('date'), fn.countAll().as('count')]) + .where((eb) => scope.applyEnvelopeScope(eb)) + .where('Envelope.createdAt', '>=', start) + .where('Envelope.createdAt', '<', end) + .groupBy('date') + .orderBy('date') + .execute(); + + const countsByDate = new Map(rows.map((row) => [row.date, toTeamAnalyticsCount(row.count)])); + + const points: TGetTeamAnalyticsDocumentsOverTimeResponse['points'] = []; + + const endTime = DateTime.fromJSDate(end, { zone: timezone }); + + // Align the cursor to the bucket boundary so it produces the same keys as + // `date_trunc` above. A custom range starting mid-month with month buckets would + // otherwise miss its first (partial) month entirely. + let cursor = DateTime.fromJSDate(start, { zone: timezone }).startOf(bucket); + + while (cursor < endTime) { + const date = cursor.toFormat('yyyy-MM-dd'); + + points.push({ + date, + count: countsByDate.get(date) ?? 0, + }); + + cursor = cursor.plus(bucket === 'month' ? { months: 1 } : { days: 1 }); + } + + const total = points.reduce((sum, point) => sum + point.count, 0); + + return { + range: resolvedRange, + total, + points, + }; +}; diff --git a/packages/lib/server-only/team/get-team-analytics-member-activity.ts b/packages/lib/server-only/team/get-team-analytics-member-activity.ts new file mode 100644 index 000000000..178aed549 --- /dev/null +++ b/packages/lib/server-only/team/get-team-analytics-member-activity.ts @@ -0,0 +1,126 @@ +import { kyselyPrisma, sql } from '@documenso/prisma'; +import type { + TGetTeamAnalyticsMemberActivityRequest, + TGetTeamAnalyticsMemberActivityResponse, +} from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { DocumentStatus } from '@prisma/client'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { + calculateTeamAnalyticsCompletionRate, + getTeamAnalyticsMembers, + getTeamAnalyticsScope, + toTeamAnalyticsCount, +} from './get-team-analytics-scope'; + +export type GetTeamAnalyticsMemberActivityOptions = TGetTeamAnalyticsMemberActivityRequest & { + userId: number; + userEmail: string; +}; + +/** + * Per-member activity for every current team member, scoped to documents the + * caller can see. Members with no visible activity are included with zeros. + * + * A document counts as "sent" by a member when the member owns the envelope and + * it has a DOCUMENT_SENT audit log inside the window. The app logs DOCUMENT_SENT + * once per document, so the earliest log within the window is used as its sent + * time (and drives "last active"). + */ +export const getTeamAnalyticsMemberActivity = async ({ + userId, + userEmail, + teamId, + range, + from, + to, + timezone, +}: GetTeamAnalyticsMemberActivityOptions): Promise => { + const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end } = resolvedRange; + + const members = await getTeamAnalyticsMembers(scope.teamId); + + if (members.length === 0) { + return { + range: resolvedRange, + members: [], + }; + } + + const memberUserIds = members.map((member) => member.id); + + const rows = await kyselyPrisma.$kysely + .with('scopedEnvelopes', (db) => + db + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.status', 'Envelope.userId']) + .where((eb) => scope.applyEnvelopeScope(eb)) + .where('Envelope.userId', 'in', memberUserIds), + ) + .with('firstSent', (db) => + db + .selectFrom('DocumentAuditLog') + .innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'DocumentAuditLog.envelopeId') + .select(({ fn }) => ['DocumentAuditLog.envelopeId', fn.min('DocumentAuditLog.createdAt').as('sentAt')]) + .where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT) + .where('DocumentAuditLog.createdAt', '>=', start) + .where('DocumentAuditLog.createdAt', '<', end) + .groupBy('DocumentAuditLog.envelopeId'), + ) + .selectFrom('firstSent') + .innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'firstSent.envelopeId') + .select(({ fn, eb }) => [ + 'scopedEnvelopes.userId', + fn.countAll().as('sent'), + fn + .countAll() + .filterWhere(eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.COMPLETED))) + .as('completed'), + fn + .countAll() + .filterWhere(eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.PENDING))) + .as('pending'), + fn.max('firstSent.sentAt').as('lastActiveAt'), + ]) + .groupBy('scopedEnvelopes.userId') + .execute(); + + const activityByUserId = new Map(rows.map((row) => [row.userId, row])); + + const memberActivity = members.map((member) => { + const activity = activityByUserId.get(member.id); + + const sent = activity ? toTeamAnalyticsCount(activity.sent) : 0; + const completed = activity ? toTeamAnalyticsCount(activity.completed) : 0; + const pending = activity ? toTeamAnalyticsCount(activity.pending) : 0; + + return { + userId: member.id, + name: member.name, + email: member.email, + avatarImageId: member.avatarImageId, + sent, + completed, + pending, + completionRate: calculateTeamAnalyticsCompletionRate({ completed, sent }), + lastActiveAt: activity?.lastActiveAt ?? null, + }; + }); + + memberActivity.sort((a, b) => { + if (a.sent !== b.sent) { + return b.sent - a.sent; + } + + return (a.name || a.email).localeCompare(b.name || b.email, undefined, { sensitivity: 'base' }); + }); + + return { + range: resolvedRange, + members: memberActivity, + }; +}; diff --git a/packages/lib/server-only/team/get-team-analytics-overview.ts b/packages/lib/server-only/team/get-team-analytics-overview.ts new file mode 100644 index 000000000..092618e9a --- /dev/null +++ b/packages/lib/server-only/team/get-team-analytics-overview.ts @@ -0,0 +1,127 @@ +import { kyselyPrisma, sql } from '@documenso/prisma'; +import type { + TGetTeamAnalyticsOverviewRequest, + TGetTeamAnalyticsOverviewResponse, +} from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { DocumentStatus } from '@prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { + calculateTeamAnalyticsCompletionRate, + getTeamAnalyticsMembers, + getTeamAnalyticsScope, + toTeamAnalyticsCount, +} from './get-team-analytics-scope'; + +export type GetTeamAnalyticsOverviewOptions = TGetTeamAnalyticsOverviewRequest & { + userId: number; + userEmail: string; +}; + +/** + * Headline team analytics: documents sent, completion rate and member activity + * for the requested window and the window immediately before it. + * + * A document counts as "sent" in a window when its first DOCUMENT_SENT audit log + * falls inside that window. + */ +export const getTeamAnalyticsOverview = async ({ + userId, + userEmail, + teamId, + range, + from, + to, + timezone, +}: GetTeamAnalyticsOverviewOptions): Promise => { + const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end, previousStart, previousEnd } = resolvedRange; + + const members = await getTeamAnalyticsMembers(scope.teamId); + const memberUserIds = members.map((member) => member.id); + + const row = await kyselyPrisma.$kysely + .with('scopedEnvelopes', (db) => + db + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.status', 'Envelope.userId']) + .where((eb) => scope.applyEnvelopeScope(eb)), + ) + .with('firstSent', (db) => + db + .selectFrom('DocumentAuditLog') + .innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'DocumentAuditLog.envelopeId') + .select(({ fn }) => ['DocumentAuditLog.envelopeId', fn.min('DocumentAuditLog.createdAt').as('sentAt')]) + .where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT) + .where('DocumentAuditLog.createdAt', '>=', previousStart) + .where('DocumentAuditLog.createdAt', '<', end) + .groupBy('DocumentAuditLog.envelopeId'), + ) + .selectFrom('firstSent') + .innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'firstSent.envelopeId') + .select(({ fn, eb }) => { + const inCurrentWindow = eb.and([eb('firstSent.sentAt', '>=', start), eb('firstSent.sentAt', '<', end)]); + + const inPreviousWindow = eb.and([ + eb('firstSent.sentAt', '>=', previousStart), + eb('firstSent.sentAt', '<', previousEnd), + ]); + + const isCompleted = eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.COMPLETED)); + + // Only current members count as active, so senders who have since left the team + // can never push "active" above "total". + const activeMemberFilter = + memberUserIds.length > 0 + ? eb.and([inCurrentWindow, eb('scopedEnvelopes.userId', 'in', memberUserIds)]) + : inCurrentWindow; + + return [ + fn.countAll().filterWhere(inCurrentWindow).as('sentCurrent'), + fn.countAll().filterWhere(inPreviousWindow).as('sentPrevious'), + fn + .countAll() + .filterWhere(eb.and([inCurrentWindow, isCompleted])) + .as('completedCurrent'), + fn + .countAll() + .filterWhere(eb.and([inPreviousWindow, isCompleted])) + .as('completedPrevious'), + fn.count('scopedEnvelopes.userId').distinct().filterWhere(activeMemberFilter).as('activeMembers'), + ]; + }) + .executeTakeFirst(); + + if (!row) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Analytics overview query returned no result', + }); + } + + const sentCurrent = toTeamAnalyticsCount(row.sentCurrent); + const sentPrevious = toTeamAnalyticsCount(row.sentPrevious); + const completedCurrent = toTeamAnalyticsCount(row.completedCurrent); + const completedPrevious = toTeamAnalyticsCount(row.completedPrevious); + + return { + range: resolvedRange, + sent: { + current: sentCurrent, + previous: sentPrevious, + }, + completionRate: { + completed: completedCurrent, + sent: sentCurrent, + rate: calculateTeamAnalyticsCompletionRate({ completed: completedCurrent, sent: sentCurrent }), + previousRate: calculateTeamAnalyticsCompletionRate({ completed: completedPrevious, sent: sentPrevious }), + }, + members: { + total: memberUserIds.length, + active: memberUserIds.length === 0 ? 0 : toTeamAnalyticsCount(row.activeMembers), + }, + }; +}; diff --git a/packages/lib/server-only/team/get-team-analytics-scope.ts b/packages/lib/server-only/team/get-team-analytics-scope.ts new file mode 100644 index 000000000..5b5b35050 --- /dev/null +++ b/packages/lib/server-only/team/get-team-analytics-scope.ts @@ -0,0 +1,202 @@ +import { prisma, sql } from '@documenso/prisma'; +import type { DB } from '@documenso/prisma/generated/types'; +import type { Prisma } from '@prisma/client'; +import { EnvelopeType, TeamMemberRole } from '@prisma/client'; +import type { Expression, ExpressionBuilder, SqlBool } from 'kysely'; + +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { buildTeamWhereQuery, getHighestTeamRoleInGroup } from '../../utils/teams'; + +export type GetTeamAnalyticsScopeOptions = { + teamId: number; + userId: number; + userEmail: string; +}; + +export type EnvelopeScopeExpressionBuilder = ExpressionBuilder; + +export type ApplyEnvelopeScope = (eb: EnvelopeScopeExpressionBuilder) => Expression; + +/** + * Authorise the caller for team analytics (ADMIN or MANAGER) and build the + * envelope visibility scope used by every analytics procedure. + * + * The visibility rule mirrors `findDocuments`: an envelope is visible when its + * visibility meets the caller's role threshold, the caller owns it, or the caller + * is a recipient. Only non-deleted team documents are considered. + */ +export const getTeamAnalyticsScope = async ({ teamId, userId, userEmail }: GetTeamAnalyticsScopeOptions) => { + const team = await prisma.team.findFirst({ + where: buildTeamWhereQuery({ + teamId, + userId, + roles: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], + }), + select: { + id: true, + teamGroups: { + where: { + organisationGroup: { + organisationGroupMembers: { + some: { + organisationMember: { + userId, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You are not allowed to view analytics for this team', + }); + } + + const role = getHighestTeamRoleInGroup(team.teamGroups); + const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[role]; + + const envelopeWhere: Prisma.EnvelopeWhereInput = { + teamId: team.id, + type: EnvelopeType.DOCUMENT, + deletedAt: null, + OR: [{ visibility: { in: allowedVisibilities } }, { userId }, { recipients: { some: { email: userEmail } } }], + }; + + /** + * Kysely predicate equivalent of `envelopeWhere`, for use in `Envelope` queries + * that need aggregates Prisma cannot express. + */ + const applyEnvelopeScope: ApplyEnvelopeScope = (eb) => + eb.and([ + eb('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)), + eb('Envelope.teamId', '=', team.id), + eb('Envelope.deletedAt', 'is', null), + eb.or([ + eb( + 'Envelope.visibility', + 'in', + allowedVisibilities.map((visibility) => sql.lit(visibility)), + ), + eb('Envelope.userId', '=', userId), + eb.exists( + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.email', '=', userEmail) + .select(sql.lit(1).as('one')), + ), + ]), + ]); + + return { + teamId: team.id, + userId, + userEmail, + role, + allowedVisibilities, + envelopeWhere, + applyEnvelopeScope, + }; +}; + +export type TeamAnalyticsScope = Awaited>; + +export type TeamAnalyticsMember = { + id: number; + name: string | null; + email: string; + avatarImageId: string | null; +}; + +/** + * Distinct users who are current members of the team (attached through any of + * the team's organisation groups). + */ +export const getTeamAnalyticsMembers = async (teamId: number): Promise => { + const members = await prisma.organisationMember.findMany({ + where: { + organisationGroupMembers: { + some: { + group: { + teamGroups: { + some: { + teamId, + }, + }, + }, + }, + }, + }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + avatarImageId: true, + }, + }, + }, + }); + + const membersByUserId = new Map(); + + for (const member of members) { + if (!membersByUserId.has(member.user.id)) { + membersByUserId.set(member.user.id, member.user); + } + } + + return Array.from(membersByUserId.values()); +}; + +/** + * Completion percentage (0-100) rounded to one decimal, or null when nothing was sent. + */ +export const calculateTeamAnalyticsCompletionRate = ({ + completed, + sent, +}: { + completed: number; + sent: number; +}): number | null => { + if (sent === 0) { + return null; + } + + return Math.round((completed / sent) * 1000) / 10; +}; + +const MAX_SAFE_COUNT = BigInt(Number.MAX_SAFE_INTEGER); + +/** + * Convert a Postgres COUNT into a JS number, throwing if the value cannot be + * represented safely. + * + * Depending on the driver, Kysely surfaces `bigint` columns as `bigint`, `string` + * or `number`, so all three are normalised here. + */ +export const toTeamAnalyticsCount = (count: string | number | bigint): number => { + let value: bigint; + + try { + value = BigInt(count); + } catch { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Analytics count is not an integer', + }); + } + + if (value < BigInt(0) || value > MAX_SAFE_COUNT) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Analytics count exceeds the safe integer range', + }); + } + + return Number(value); +}; diff --git a/packages/lib/server-only/team/get-team-analytics-status-breakdown.ts b/packages/lib/server-only/team/get-team-analytics-status-breakdown.ts new file mode 100644 index 000000000..66a070bbd --- /dev/null +++ b/packages/lib/server-only/team/get-team-analytics-status-breakdown.ts @@ -0,0 +1,70 @@ +import { prisma } from '@documenso/prisma'; +import type { + TGetTeamAnalyticsStatusBreakdownRequest, + TGetTeamAnalyticsStatusBreakdownResponse, +} from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { DocumentStatus } from '@prisma/client'; + +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { getTeamAnalyticsScope } from './get-team-analytics-scope'; + +export type GetTeamAnalyticsStatusBreakdownOptions = TGetTeamAnalyticsStatusBreakdownRequest & { + userId: number; + userEmail: string; +}; + +/** + * Current status of every visible document created inside the requested window. + */ +export const getTeamAnalyticsStatusBreakdown = async ({ + userId, + userEmail, + teamId, + range, + from, + to, + timezone, +}: GetTeamAnalyticsStatusBreakdownOptions): Promise => { + const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end } = resolvedRange; + + const groups = await prisma.envelope.groupBy({ + by: ['status'], + where: { + ...scope.envelopeWhere, + createdAt: { + gte: start, + lt: end, + }, + }, + _count: { + _all: true, + }, + }); + + const counts: Record = { + [DocumentStatus.DRAFT]: 0, + [DocumentStatus.PENDING]: 0, + [DocumentStatus.COMPLETED]: 0, + [DocumentStatus.REJECTED]: 0, + [DocumentStatus.CANCELLED]: 0, + }; + + for (const group of groups) { + counts[group.status] = group._count._all; + } + + const total = Object.values(counts).reduce((sum, count) => sum + count, 0); + + return { + range: resolvedRange, + total, + draft: counts[DocumentStatus.DRAFT], + pending: counts[DocumentStatus.PENDING], + completed: counts[DocumentStatus.COMPLETED], + rejected: counts[DocumentStatus.REJECTED], + cancelled: counts[DocumentStatus.CANCELLED], + }; +}; diff --git a/packages/lib/server-only/team/get-team-analytics-template-usage.ts b/packages/lib/server-only/team/get-team-analytics-template-usage.ts new file mode 100644 index 000000000..dd85ffe08 --- /dev/null +++ b/packages/lib/server-only/team/get-team-analytics-template-usage.ts @@ -0,0 +1,119 @@ +import { prisma } from '@documenso/prisma'; +import type { + TGetTeamAnalyticsTemplateUsageRequest, + TGetTeamAnalyticsTemplateUsageResponse, +} from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { EnvelopeType } from '@prisma/client'; + +import { mapTemplateIdToSecondaryId } from '../../utils/envelope'; +import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range'; +import { getTeamAnalyticsScope } from './get-team-analytics-scope'; + +export type GetTeamAnalyticsTemplateUsageOptions = TGetTeamAnalyticsTemplateUsageRequest & { + userId: number; + userEmail: string; +}; + +/** + * Templates ranked by how many visible documents were created from them inside the + * requested window. Template metadata is null when the template has been deleted or + * is not visible to the caller. + */ +export const getTeamAnalyticsTemplateUsage = async ({ + userId, + userEmail, + teamId, + range, + from, + to, + timezone, + limit, +}: GetTeamAnalyticsTemplateUsageOptions): Promise => { + const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail }); + const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone }); + + const { start, end } = resolvedRange; + + const groups = await prisma.envelope.groupBy({ + by: ['templateId'], + where: { + ...scope.envelopeWhere, + templateId: { + not: null, + }, + createdAt: { + gte: start, + lt: end, + }, + }, + _count: { + _all: true, + }, + orderBy: [ + { + _count: { + templateId: 'desc', + }, + }, + { + templateId: 'asc', + }, + ], + take: limit, + }); + + const usage = groups.flatMap((group) => { + if (group.templateId === null) { + return []; + } + + return [ + { + templateId: group.templateId, + count: group._count._all, + }, + ]; + }); + + if (usage.length === 0) { + return { + range: resolvedRange, + templates: [], + }; + } + + const templates = await prisma.envelope.findMany({ + where: { + type: EnvelopeType.TEMPLATE, + teamId: scope.teamId, + deletedAt: null, + secondaryId: { + in: usage.map(({ templateId }) => mapTemplateIdToSecondaryId(templateId)), + }, + OR: [{ visibility: { in: scope.allowedVisibilities } }, { userId }], + }, + select: { + id: true, + secondaryId: true, + title: true, + updatedAt: true, + }, + }); + + const templatesBySecondaryId = new Map(templates.map((template) => [template.secondaryId, template])); + + return { + range: resolvedRange, + templates: usage.map(({ templateId, count }) => { + const template = templatesBySecondaryId.get(mapTemplateIdToSecondaryId(templateId)); + + return { + id: templateId, + envelopeId: template?.id ?? null, + title: template?.title ?? null, + updatedAt: template?.updatedAt ?? null, + count, + }; + }), + }; +}; diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts index b643ba6aa..794af237f 100644 --- a/packages/lib/utils/organisations.ts +++ b/packages/lib/utils/organisations.ts @@ -1,6 +1,6 @@ import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/client'; -import { DocumentVisibility, type OrganisationGroup, type OrganisationMemberRole } from '@prisma/client'; +import { DocumentVisibility, type OrganisationGroup, OrganisationMemberRole } from '@prisma/client'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats'; import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration'; @@ -30,6 +30,18 @@ export const canExecuteOrganisationAction = ( return ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP[action].some((i) => i === role); }; +/** + * Organisation analytics are restricted to organisation admins, unlike organisation + * settings which managers can also access. + */ +export const canAccessOrganisationAnalytics = (role: keyof typeof ORGANISATION_MEMBER_ROLE_MAP) => { + return role === OrganisationMemberRole.ADMIN; +}; + +export const formatOrganisationAnalyticsPath = (organisationUrl: string) => { + return `/o/${organisationUrl}/analytics`; +}; + /** * Compares the provided `currentUserRole` with the provided `roleToCheck` to determine * whether the `currentUserRole` has permission to modify the `roleToCheck`. diff --git a/packages/lib/utils/team-analytics-range.test.ts b/packages/lib/utils/team-analytics-range.test.ts new file mode 100644 index 000000000..71ea12831 --- /dev/null +++ b/packages/lib/utils/team-analytics-range.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from 'vitest'; + +import { AppErrorCode } from '../errors/app-error'; +import { resolveTeamAnalyticsRange } from './team-analytics-range'; + +const invalidRequest = expect.objectContaining({ code: AppErrorCode.INVALID_REQUEST }); + +describe('resolveTeamAnalyticsRange', () => { + describe('presets', () => { + it('ends at the start of tomorrow so today is included', () => { + const resolved = resolveTeamAnalyticsRange({ + range: '7d', + timezone: 'UTC', + now: new Date('2025-01-15T12:34:56.000Z'), + }); + + expect(resolved).toEqual({ + range: '7d', + from: '2025-01-09', + to: '2025-01-15', + timezone: 'UTC', + start: new Date('2025-01-09T00:00:00.000Z'), + end: new Date('2025-01-16T00:00:00.000Z'), + previousStart: new Date('2025-01-02T00:00:00.000Z'), + previousEnd: new Date('2025-01-09T00:00:00.000Z'), + bucket: 'day', + }); + }); + + it('keeps boundaries on local midnight across the March DST change in America/New_York', () => { + // DST began on 2024-03-10 in New York (EST -05:00 -> EDT -04:00). + const resolved = resolveTeamAnalyticsRange({ + range: '7d', + timezone: 'America/New_York', + now: new Date('2024-03-12T18:00:00.000Z'), + }); + + // 2024-03-13T00:00 EDT + expect(resolved.end).toEqual(new Date('2024-03-13T04:00:00.000Z')); + // 2024-03-06T00:00 EST (before the change), still local midnight. + expect(resolved.start).toEqual(new Date('2024-03-06T05:00:00.000Z')); + // 2024-02-28T00:00 EST + expect(resolved.previousStart).toEqual(new Date('2024-02-28T05:00:00.000Z')); + }); + + it('resolves 30d and 90d as calendar-day windows', () => { + const now = new Date('2025-06-30T23:59:59.000Z'); + + const thirty = resolveTeamAnalyticsRange({ range: '30d', timezone: 'UTC', now }); + + expect(thirty.end).toEqual(new Date('2025-07-01T00:00:00.000Z')); + expect(thirty.start).toEqual(new Date('2025-06-01T00:00:00.000Z')); + expect(thirty.previousStart).toEqual(new Date('2025-05-02T00:00:00.000Z')); + expect(thirty.previousEnd).toEqual(thirty.start); + expect(thirty.from).toBe('2025-06-01'); + expect(thirty.to).toBe('2025-06-30'); + + const ninety = resolveTeamAnalyticsRange({ range: '90d', timezone: 'UTC', now }); + + expect(ninety.end).toEqual(new Date('2025-07-01T00:00:00.000Z')); + expect(ninety.start).toEqual(new Date('2025-04-02T00:00:00.000Z')); + expect(ninety.previousStart).toEqual(new Date('2025-01-02T00:00:00.000Z')); + expect(ninety.from).toBe('2025-04-02'); + expect(ninety.to).toBe('2025-06-30'); + }); + + it('aligns 12m to the start of the month 11 months ago with monthly buckets', () => { + const resolved = resolveTeamAnalyticsRange({ + range: '12m', + timezone: 'UTC', + now: new Date('2025-03-20T10:00:00.000Z'), + }); + + expect(resolved).toEqual({ + range: '12m', + from: '2024-04-01', + to: '2025-03-20', + timezone: 'UTC', + start: new Date('2024-04-01T00:00:00.000Z'), + end: new Date('2025-03-21T00:00:00.000Z'), + previousStart: new Date('2023-04-01T00:00:00.000Z'), + previousEnd: new Date('2024-04-01T00:00:00.000Z'), + bucket: 'month', + }); + }); + + it('aligns 12m month boundaries to the request timezone', () => { + // 2025-01-01T03:00Z is still 2024-12-31 in Los Angeles. + const resolved = resolveTeamAnalyticsRange({ + range: '12m', + timezone: 'America/Los_Angeles', + now: new Date('2025-01-01T03:00:00.000Z'), + }); + + // 2024-01-01T00:00 PST + expect(resolved.start).toEqual(new Date('2024-01-01T08:00:00.000Z')); + // 2025-01-01T00:00 PST + expect(resolved.end).toEqual(new Date('2025-01-01T08:00:00.000Z')); + // 2023-01-01T00:00 PST + expect(resolved.previousStart).toEqual(new Date('2023-01-01T08:00:00.000Z')); + expect(resolved.from).toBe('2024-01-01'); + expect(resolved.to).toBe('2024-12-31'); + }); + + it('ignores from/to for presets', () => { + const resolved = resolveTeamAnalyticsRange({ + range: '7d', + from: '2020-01-01', + to: '2020-01-02', + timezone: 'UTC', + now: new Date('2025-01-15T12:34:56.000Z'), + }); + + expect(resolved.from).toBe('2025-01-09'); + expect(resolved.to).toBe('2025-01-15'); + }); + + it('rejects invalid timezones', () => { + expect(() => + resolveTeamAnalyticsRange({ + range: '30d', + timezone: 'Not/A_Zone', + now: new Date('2025-01-15T12:00:00.000Z'), + }), + ).toThrow(invalidRequest); + }); + }); + + describe('custom', () => { + const now = new Date('2025-01-15T12:00:00.000Z'); + + it('resolves an inclusive calendar window with an equally sized previous window', () => { + const resolved = resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-02-01', + to: '2024-02-29', + timezone: 'UTC', + now, + }); + + // 29 days (leap February), previous window is the 29 days ending Jan 31. + expect(resolved).toEqual({ + range: 'custom', + from: '2024-02-01', + to: '2024-02-29', + timezone: 'UTC', + start: new Date('2024-02-01T00:00:00.000Z'), + end: new Date('2024-03-01T00:00:00.000Z'), + previousStart: new Date('2024-01-03T00:00:00.000Z'), + previousEnd: new Date('2024-02-01T00:00:00.000Z'), + bucket: 'day', + }); + }); + + it('uses day buckets up to 92 days and month buckets beyond', () => { + // Apr 1 .. Jul 1 2024 inclusive is 92 days. + const ninetyTwo = resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-04-01', + to: '2024-07-01', + timezone: 'UTC', + now, + }); + + expect(ninetyTwo.bucket).toBe('day'); + + const ninetyThree = resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-04-01', + to: '2024-07-02', + timezone: 'UTC', + now, + }); + + expect(ninetyThree.bucket).toBe('month'); + expect(ninetyThree.start).toEqual(new Date('2024-04-01T00:00:00.000Z')); + expect(ninetyThree.end).toEqual(new Date('2024-07-03T00:00:00.000Z')); + expect(ninetyThree.previousStart).toEqual(new Date('2023-12-30T00:00:00.000Z')); + }); + + it('allows a single-day range and a range ending today', () => { + const resolved = resolveTeamAnalyticsRange({ + range: 'custom', + from: '2025-01-15', + to: '2025-01-15', + timezone: 'UTC', + now, + }); + + expect(resolved.start).toEqual(new Date('2025-01-15T00:00:00.000Z')); + expect(resolved.end).toEqual(new Date('2025-01-16T00:00:00.000Z')); + expect(resolved.previousStart).toEqual(new Date('2025-01-14T00:00:00.000Z')); + }); + + it('keeps local midnight bounds across the March DST change in America/New_York', () => { + // DST began on 2024-03-10 in New York (EST -05:00 -> EDT -04:00). + const resolved = resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-03-06', + to: '2024-03-12', + timezone: 'America/New_York', + now, + }); + + // 2024-03-06T00:00 EST + expect(resolved.start).toEqual(new Date('2024-03-06T05:00:00.000Z')); + // 2024-03-13T00:00 EDT + expect(resolved.end).toEqual(new Date('2024-03-13T04:00:00.000Z')); + // 7 calendar days, not 7 * 24h: 2024-02-28T00:00 EST + expect(resolved.previousStart).toEqual(new Date('2024-02-28T05:00:00.000Z')); + expect(resolved.from).toBe('2024-03-06'); + expect(resolved.to).toBe('2024-03-12'); + expect(resolved.bucket).toBe('day'); + }); + + it('evaluates "today" in the request timezone', () => { + // 2025-01-15T03:00Z is still 2025-01-14 in Los Angeles, so 2025-01-15 is in the future there. + const lateNow = new Date('2025-01-15T03:00:00.000Z'); + + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + from: '2025-01-10', + to: '2025-01-15', + timezone: 'America/Los_Angeles', + now: lateNow, + }), + ).toThrow(invalidRequest); + + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + from: '2025-01-10', + to: '2025-01-15', + timezone: 'UTC', + now: lateNow, + }), + ).not.toThrow(); + }); + + it('rejects from after to', () => { + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-02-10', + to: '2024-02-01', + timezone: 'UTC', + now, + }), + ).toThrow(invalidRequest); + }); + + it('rejects a missing bound', () => { + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-02-01', + timezone: 'UTC', + now, + }), + ).toThrow(invalidRequest); + + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + to: '2024-02-01', + timezone: 'UTC', + now, + }), + ).toThrow(invalidRequest); + }); + + it('rejects a to in the future', () => { + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + from: '2025-01-01', + to: '2025-01-16', + timezone: 'UTC', + now, + }), + ).toThrow(invalidRequest); + }); + + it('rejects ranges starting more than a year and a day ago', () => { + // now is 2025-01-15, so the earliest allowed start is 2024-01-14. + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-01-13', + to: '2024-03-10', + timezone: 'UTC', + now, + }), + ).toThrow(invalidRequest); + + // Exactly a year and a day ago is allowed, up to today. + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-01-14', + to: '2025-01-15', + timezone: 'UTC', + now, + }), + ).not.toThrow(); + }); + + it('rejects malformed or non-existent dates', () => { + for (const to of ['2024-02-30', '2024-13-01', '2024-02-1', '2024-02-01T00:00:00Z', 'yesterday']) { + expect(() => + resolveTeamAnalyticsRange({ + range: 'custom', + from: '2024-06-01', + to, + timezone: 'UTC', + now, + }), + ).toThrow(invalidRequest); + } + }); + }); +}); diff --git a/packages/lib/utils/team-analytics-range.ts b/packages/lib/utils/team-analytics-range.ts new file mode 100644 index 000000000..bda6e20c7 --- /dev/null +++ b/packages/lib/utils/team-analytics-range.ts @@ -0,0 +1,174 @@ +import type { + TTeamAnalyticsRange, + TTeamAnalyticsResolvedRange, +} from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types'; +import { DateTime, IANAZone } from 'luxon'; + +import { AppError, AppErrorCode } from '../errors/app-error'; + +const DAY_RANGE_LENGTHS: Record, number> = { + '7d': 7, + '30d': 30, + '90d': 90, +}; + +/** Custom ranges longer than this many days are bucketed by month instead of by day. */ +const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92; + +const DATE_FORMAT = 'yyyy-MM-dd'; + +export type ResolveTeamAnalyticsRangeOptions = { + range: TTeamAnalyticsRange; + /** Inclusive start date (yyyy-MM-dd) in `timezone`; required when `range` is `custom`. */ + from?: string; + /** Inclusive end date (yyyy-MM-dd) in `timezone`; required when `range` is `custom`. */ + to?: string; + timezone: string; + now?: Date; +}; + +/** + * Resolve an analytics range into a half-open [start, end) window in the given IANA + * timezone, plus the equally sized window immediately preceding it. + * + * For presets `end` is always the start of tomorrow in the timezone so that today is + * included. For `custom` the window is [from, to] inclusive as calendar days. + */ +export const resolveTeamAnalyticsRange = ({ + range, + from, + to, + timezone, + now = new Date(), +}: ResolveTeamAnalyticsRangeOptions): TTeamAnalyticsResolvedRange => { + if (!IANAZone.isValidZone(timezone)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Invalid analytics timezone', + }); + } + + const currentTime = DateTime.fromJSDate(now, { zone: timezone }); + + if (!currentTime.isValid) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Invalid analytics reference time', + }); + } + + const today = currentTime.startOf('day'); + + if (range === 'custom') { + return resolveCustomRange({ from, to, timezone, today }); + } + + const end = today.plus({ days: 1 }); + + if (range === '12m') { + const start = today.startOf('month').minus({ months: 11 }); + + return { + range, + from: start.toFormat(DATE_FORMAT), + to: today.toFormat(DATE_FORMAT), + timezone, + start: start.toJSDate(), + end: end.toJSDate(), + previousStart: start.minus({ months: 12 }).toJSDate(), + previousEnd: start.toJSDate(), + bucket: 'month', + }; + } + + const days = DAY_RANGE_LENGTHS[range]; + const start = end.minus({ days }); + const previousStart = start.minus({ days }); + + return { + range, + from: start.toFormat(DATE_FORMAT), + to: today.toFormat(DATE_FORMAT), + timezone, + start: start.toJSDate(), + end: end.toJSDate(), + previousStart: previousStart.toJSDate(), + previousEnd: start.toJSDate(), + bucket: 'day', + }; +}; + +const resolveCustomRange = ({ + from, + to, + timezone, + today, +}: { + from?: string; + to?: string; + timezone: string; + today: DateTime; +}): TTeamAnalyticsResolvedRange => { + if (!from || !to) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Custom analytics range requires both "from" and "to" dates', + }); + } + + const fromDate = parseCalendarDate(from, timezone, 'from'); + const toDate = parseCalendarDate(to, timezone, 'to'); + + if (fromDate > toDate) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Custom analytics range "from" must not be after "to"', + }); + } + + if (toDate > today) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Custom analytics range "to" must not be in the future', + }); + } + + const earliestFrom = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK); + + if (fromDate < earliestFrom) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Custom analytics range must start within the last year', + }); + } + + const start = fromDate; + const end = toDate.plus({ days: 1 }); + + // Count calendar days rather than elapsed time so DST transitions do not skew the span. + const spanDays = Math.round(end.diff(start, 'days').days); + + const previousStart = start.minus({ days: spanDays }); + + return { + range: 'custom', + from: start.toFormat(DATE_FORMAT), + to: toDate.toFormat(DATE_FORMAT), + timezone, + start: start.toJSDate(), + end: end.toJSDate(), + previousStart: previousStart.toJSDate(), + previousEnd: start.toJSDate(), + bucket: spanDays > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day', + }; +}; + +/** Parse a strict yyyy-MM-dd calendar date at local midnight in `timezone`. */ +const parseCalendarDate = (value: string, timezone: string, field: 'from' | 'to'): DateTime => { + const parsed = DateTime.fromISO(value, { zone: timezone }); + + // Round-trip guard: rejects non-date ISO strings (e.g. datetimes) and overflowing + // dates such as 2023-02-30 that luxon would otherwise flag as invalid anyway. + if (!parsed.isValid || parsed.toISODate() !== value) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Invalid custom analytics range "${field}" date, expected yyyy-MM-dd`, + }); + } + + return parsed.startOf('day'); +}; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts index c31e6ccc8..8382f8e89 100644 --- a/packages/lib/utils/teams.ts +++ b/packages/lib/utils/teams.ts @@ -33,6 +33,10 @@ export const formatTemplatesPath = (teamUrl: string) => { return `/t/${teamUrl}/templates`; }; +export const formatAnalyticsPath = (teamUrl: string) => { + return `/t/${teamUrl}/analytics`; +}; + /** * Determines whether a team member can execute a given action. * diff --git a/packages/prisma/seed/analytics-seed.ts b/packages/prisma/seed/analytics-seed.ts new file mode 100644 index 000000000..6568347ca --- /dev/null +++ b/packages/prisma/seed/analytics-seed.ts @@ -0,0 +1,941 @@ +import { hashSync } from '@documenso/lib/server-only/auth/hash'; +import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation'; +import { createTeam } from '@documenso/lib/server-only/team/create-team'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; +import { createTeamMembers } from '@documenso/trpc/server/team-router/create-team-members'; +import { nanoid } from 'nanoid'; + +import { prisma } from '..'; +import type { User } from '../client'; +import { + DocumentStatus, + DocumentVisibility, + EnvelopeType, + OrganisationGroupType, + OrganisationMemberRole, + ReadStatus, + SendStatus, + SigningStatus, + TeamMemberRole, +} from '../client'; +import { seedBlankDocument } from './documents'; +import { seedBlankTemplate } from './templates'; + +/** + * One-off seed script: creates three teams with analytics-friendly data inside + * the organisation owned by `admin@documenso.com` (created by `initial-seed.ts`). + * + * Run via: + * npm run with:env -- tsx packages/prisma/seed/analytics-seed.ts + * + * Produces (idempotent: an existing team with the same URL is deleted and recreated): + * - analytics-quiet "Quiet Team" 2 members, 3 documents, no templates + * - analytics-steady "Steady Team" 4 members, ~25 documents over 90 days, 2 templates + * - analytics-busy "Busy Team" 8 members, ~180 documents over 12 months, 5 templates + * + * Definitions used by the analytics dashboard: + * - "sent" = a DOCUMENT_SENT audit log (one per non-DRAFT document) + * - "created" = Envelope.createdAt + * - "created from template" = Envelope.templateId (numeric template id) + * - "members" = organisation members attached to the team's role groups + */ + +const ADMIN_EMAIL = 'admin@documenso.com'; +const ADMIN_PASSWORD = 'password'; +const MEMBER_EMAIL_DOMAIN = 'test.documenso.com'; +const WEBAPP_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:49000'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; + +const NOW = new Date(); + +// --------------------------------------------------------------------------- +// Deterministic pseudo random (LCG) so re-runs produce the same shape. +// --------------------------------------------------------------------------- + +let seedState = 20260922; + +const resetRandom = (seed: number) => { + seedState = seed; +}; + +const rand = () => { + seedState = (seedState * 1103515245 + 12345) & 0x7fffffff; + return seedState / 0x7fffffff; +}; + +const randInt = (minInclusive: number, maxInclusive: number) => + minInclusive + Math.floor(rand() * (maxInclusive - minInclusive + 1)); + +const pick = (items: readonly T[]): T => items[Math.floor(rand() * items.length)]; + +const shuffle = (items: T[]): T[] => { + const result = [...items]; + + for (let i = result.length - 1; i > 0; i -= 1) { + const j = Math.floor(rand() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + + return result; +}; + +const pickWeightedIndex = (weights: number[]): number => { + const total = weights.reduce((sum, weight) => sum + weight, 0); + let cursor = rand() * total; + + for (let i = 0; i < weights.length; i += 1) { + cursor -= weights[i]; + + if (cursor <= 0) { + return i; + } + } + + return weights.length - 1; +}; + +// --------------------------------------------------------------------------- +// Time helpers +// --------------------------------------------------------------------------- + +const notInFuture = (date: Date) => (date.getTime() > NOW.getTime() ? new Date(NOW.getTime() - MINUTE_MS) : date); + +/** + * A timestamp `daysAgo` days back, at a random working hour (09:00-17:59 local). + */ +const atDaysAgo = (daysAgo: number) => { + const date = new Date(NOW.getTime() - daysAgo * DAY_MS); + date.setHours(randInt(9, 17), randInt(0, 59), randInt(0, 59), 0); + + return notInFuture(date); +}; + +const isWeekend = (daysAgo: number) => { + const day = new Date(NOW.getTime() - daysAgo * DAY_MS).getDay(); + + return day === 0 || day === 6; +}; + +// --------------------------------------------------------------------------- +// Static content +// --------------------------------------------------------------------------- + +const DOCUMENT_TITLES = [ + 'Master Services Agreement - Northwind', + 'NDA - Contoso Partnership', + 'Employment Offer - J. Alvarez', + 'SOW #14 - Platform Migration', + 'Vendor Agreement - Acme Logistics', + 'Lease Renewal - 12 Harbour St', + 'Consulting Agreement - Q3', + 'Data Processing Addendum - Fabrikam', + 'Contractor Agreement - M. Chen', + 'Purchase Order 2026-0917', + 'Reseller Agreement - Globex', + 'Board Resolution - September', + 'Equity Grant - S. Patel', + 'Sponsorship Agreement - DevConf', + 'Freelance Contract - Design Sprint', + 'Insurance Certificate - Fleet', + 'IP Assignment - Project Atlas', + 'Subscription Renewal - Initech', + 'Change Order #3 - Warehouse Fitout', + 'Referral Agreement - Umbrella Corp', +]; + +const RECIPIENT_NAMES = [ + 'Ava Thompson', + 'Liam Okafor', + 'Sofia Martinez', + 'Noah Kimura', + 'Isabella Rossi', + 'Ethan Brooks', + 'Mia Johansson', + 'Lucas Ferreira', +]; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type MemberSpec = { + name: string; + role: TeamMemberRole; +}; + +type TemplateSpec = { + title: string; + usage: number; +}; + +type DocumentSpec = { + daysAgo: number; + status: DocumentStatus; + senderIndex: number; + visibility: DocumentVisibility; + templateIndex?: number; +}; + +type TeamSpec = { + url: string; + name: string; + members: MemberSpec[]; + templates: TemplateSpec[]; + buildDocuments: () => DocumentSpec[]; +}; + +type SeededTeamSummary = { + url: string; + name: string; + memberCount: number; + templateCount: number; + documentsByStatus: Record; + documentsByVisibility: Record; + documentsFromTemplates: number; +}; + +// --------------------------------------------------------------------------- +// Document spec builders +// --------------------------------------------------------------------------- + +/** + * Builds an exact status pool from ratios, then shuffles it so the statuses are + * spread across the timeline rather than clustered. + */ +const buildStatusPool = (total: number, ratios: Partial>): DocumentStatus[] => { + const entries = Object.entries(ratios) as [DocumentStatus, number][]; + const pool: DocumentStatus[] = []; + + for (const [status, ratio] of entries) { + const count = Math.round(total * ratio); + + for (let i = 0; i < count; i += 1) { + pool.push(status); + } + } + + // Round-off correction so the pool has exactly `total` entries. + while (pool.length < total) { + pool.push(DocumentStatus.COMPLETED); + } + + while (pool.length > total) { + pool.pop(); + } + + return shuffle(pool); +}; + +/** + * Assigns template indexes to documents that are not drafts. Templates are + * assigned in usage order so the ranking in the dashboard is stable. + */ +const assignTemplates = (specs: DocumentSpec[], templates: TemplateSpec[]) => { + const candidates = shuffle( + specs.map((_spec, index) => index).filter((index) => specs[index].status !== DocumentStatus.DRAFT), + ); + + let cursor = 0; + + templates.forEach((template, templateIndex) => { + for (let i = 0; i < template.usage; i += 1) { + const specIndex = candidates[cursor]; + + if (specIndex === undefined) { + throw new Error(`Not enough documents to satisfy template usage for "${template.title}"`); + } + + specs[specIndex].templateIndex = templateIndex; + cursor += 1; + } + }); +}; + +/** + * Assigns restricted visibilities to documents sent by the admin (sender index 0), + * so managers cannot see them through the "owner" escape hatch. + */ +const assignVisibilities = ( + specs: DocumentSpec[], + counts: { admin: number; managerAndAbove: number }, + adminSenderIndex: number, +) => { + const candidates = shuffle(specs.map((_spec, index) => index)); + let assigned = { admin: 0, managerAndAbove: 0 }; + + for (const specIndex of candidates) { + if (assigned.admin >= counts.admin && assigned.managerAndAbove >= counts.managerAndAbove) { + break; + } + + const spec = specs[specIndex]; + + if (assigned.admin < counts.admin) { + spec.visibility = DocumentVisibility.ADMIN; + spec.senderIndex = adminSenderIndex; + assigned = { ...assigned, admin: assigned.admin + 1 }; + continue; + } + + spec.visibility = DocumentVisibility.MANAGER_AND_ABOVE; + spec.senderIndex = adminSenderIndex; + assigned = { ...assigned, managerAndAbove: assigned.managerAndAbove + 1 }; + } +}; + +const buildQuietDocuments = (): DocumentSpec[] => [ + { daysAgo: 20, status: DocumentStatus.DRAFT, senderIndex: 0, visibility: DocumentVisibility.EVERYONE }, + { daysAgo: 45, status: DocumentStatus.DRAFT, senderIndex: 1, visibility: DocumentVisibility.EVERYONE }, + { daysAgo: 8, status: DocumentStatus.PENDING, senderIndex: 0, visibility: DocumentVisibility.EVERYONE }, +]; + +const STEADY_TEMPLATES: TemplateSpec[] = [ + { title: 'Mutual NDA', usage: 4 }, + { title: 'Contractor Agreement', usage: 2 }, +]; + +const buildSteadyDocuments = (): DocumentSpec[] => { + // ~10 in the last 30 days, ~8 in days 30-59, ~7 in days 60-89 => 25 total. + const dayBuckets: [number, number, number][] = [ + [0, 29, 10], + [30, 59, 8], + [60, 89, 7], + ]; + + const daysAgoList: number[] = []; + + for (const [from, to, count] of dayBuckets) { + for (let i = 0; i < count; i += 1) { + daysAgoList.push(randInt(from, to)); + } + } + + const statuses = buildStatusPool(daysAgoList.length, { + [DocumentStatus.COMPLETED]: 0.6, + [DocumentStatus.PENDING]: 0.2, + [DocumentStatus.DRAFT]: 0.1, + [DocumentStatus.REJECTED]: 0.05, + [DocumentStatus.CANCELLED]: 0.05, + }); + + // Senders: admin (0), manager (1) and one member (2). + const specs: DocumentSpec[] = daysAgoList.map((daysAgo, index) => ({ + daysAgo, + status: statuses[index], + senderIndex: index % 3, + visibility: DocumentVisibility.EVERYONE, + })); + + assignTemplates(specs, STEADY_TEMPLATES); + assignVisibilities(specs, { admin: 2, managerAndAbove: 2 }, 0); + + return specs; +}; + +const BUSY_TEMPLATES: TemplateSpec[] = [ + { title: 'Mutual NDA', usage: 40 }, + { title: 'Sales Order Form', usage: 25 }, + { title: 'Contractor Agreement', usage: 15 }, + { title: 'Offer Letter', usage: 8 }, + { title: 'Board Consent', usage: 3 }, +]; + +const BUSY_DOCUMENT_COUNT = 180; +const BUSY_WINDOW_DAYS = 365; + +const buildBusyDocuments = (): DocumentSpec[] => { + // Weight each day so recent weekdays are far more likely than old weekend days. + const weights = Array.from({ length: BUSY_WINDOW_DAYS }, (_, daysAgo) => { + const recency = 1 - daysAgo / BUSY_WINDOW_DAYS; + const trend = 0.3 + 1.7 * recency; + const weekdayFactor = isWeekend(daysAgo) ? 0.2 : 1; + + return trend * weekdayFactor; + }); + + const daysAgoList = Array.from({ length: BUSY_DOCUMENT_COUNT }, () => pickWeightedIndex(weights)); + + const statuses = buildStatusPool(daysAgoList.length, { + [DocumentStatus.COMPLETED]: 0.7, + [DocumentStatus.PENDING]: 0.15, + [DocumentStatus.DRAFT]: 0.08, + [DocumentStatus.REJECTED]: 0.04, + [DocumentStatus.CANCELLED]: 0.03, + }); + + // Six senders out of eight members, with uneven volume. + const senderWeights = [3, 5, 4, 2, 3, 1]; + + const specs: DocumentSpec[] = daysAgoList.map((daysAgo, index) => ({ + daysAgo, + status: statuses[index], + senderIndex: pickWeightedIndex(senderWeights), + visibility: DocumentVisibility.EVERYONE, + })); + + assignTemplates(specs, BUSY_TEMPLATES); + assignVisibilities(specs, { admin: 6, managerAndAbove: 0 }, 0); + + return specs; +}; + +// --------------------------------------------------------------------------- +// Team specs +// --------------------------------------------------------------------------- + +const TEAM_SPECS: TeamSpec[] = [ + { + url: 'analytics-quiet', + name: 'Quiet Team', + members: [{ name: 'Harper Quinn', role: TeamMemberRole.MEMBER }], + templates: [], + buildDocuments: buildQuietDocuments, + }, + { + url: 'analytics-steady', + name: 'Steady Team', + members: [ + { name: 'Marcus Lindqvist', role: TeamMemberRole.MANAGER }, + { name: 'Elena Rossi', role: TeamMemberRole.MEMBER }, + { name: 'Priya Natarajan', role: TeamMemberRole.MEMBER }, + ], + templates: STEADY_TEMPLATES, + buildDocuments: buildSteadyDocuments, + }, + { + url: 'analytics-busy', + name: 'Busy Team', + members: [ + { name: 'Jonas Weber', role: TeamMemberRole.ADMIN }, + { name: 'Amara Okonkwo', role: TeamMemberRole.MANAGER }, + { name: 'Diego Alvarez', role: TeamMemberRole.MEMBER }, + { name: 'Hana Sato', role: TeamMemberRole.MEMBER }, + { name: 'Oliver Bennett', role: TeamMemberRole.MEMBER }, + { name: 'Chloe Dubois', role: TeamMemberRole.MEMBER }, + { name: 'Ravi Menon', role: TeamMemberRole.MEMBER }, + ], + templates: BUSY_TEMPLATES, + buildDocuments: buildBusyDocuments, + }, +]; + +// --------------------------------------------------------------------------- +// Seeding helpers +// --------------------------------------------------------------------------- + +const toEmailSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '.'); + +const getAdminUserAndOrganisation = async () => { + const admin = await prisma.user.findFirst({ + where: { + email: ADMIN_EMAIL, + }, + }); + + if (!admin) { + throw new Error(`User ${ADMIN_EMAIL} not found. Run the initial seed first (npm run prisma:seed).`); + } + + const organisation = await prisma.organisation.findFirst({ + where: { + ownerUserId: admin.id, + }, + include: { + groups: true, + }, + }); + + if (!organisation) { + throw new Error(`No organisation owned by ${ADMIN_EMAIL} was found.`); + } + + return { admin, organisation }; +}; + +/** + * Deletes an existing team with the given URL (and its documents) so the seed can + * recreate it from scratch. Mirrors the cleanup done by `deleteTeam`. + */ +const deleteExistingTeam = async (teamUrl: string, organisationId: string) => { + const existingTeam = await prisma.team.findUnique({ + where: { + url: teamUrl, + }, + include: { + teamGroups: { + select: { + organisationGroupId: true, + }, + }, + }, + }); + + if (!existingTeam) { + return false; + } + + if (existingTeam.organisationId !== organisationId) { + throw new Error(`Team "${teamUrl}" exists but belongs to a different organisation. Aborting.`); + } + + // Captured before the delete cascades the team groups away, so only the groups + // that belonged to this team are considered for cleanup. + const organisationGroupIds = existingTeam.teamGroups.map((teamGroup) => teamGroup.organisationGroupId); + + // Audit logs are only SetNull on envelope delete, so purge them explicitly. + await prisma.documentAuditLog.deleteMany({ + where: { + envelope: { + teamId: existingTeam.id, + }, + }, + }); + + await prisma.$transaction(async (tx) => { + await tx.team.delete({ + where: { + id: existingTeam.id, + }, + }); + + await tx.organisationGroup.deleteMany({ + where: { + id: { + in: organisationGroupIds, + }, + type: OrganisationGroupType.INTERNAL_TEAM, + teamGroups: { + none: {}, + }, + }, + }); + }); + + return true; +}; + +/** + * Finds or creates a user and makes sure they are an organisation member. + * Returns the user and their organisation member id. + */ +const ensureOrganisationMember = async ({ + name, + email, + organisationId, +}: { + name: string; + email: string; + organisationId: string; +}) => { + let user = await prisma.user.findFirst({ + where: { + email, + }, + }); + + if (!user) { + user = await prisma.user.create({ + data: { + name, + email, + password: hashSync(ADMIN_PASSWORD), + emailVerified: new Date(), + }, + }); + } + + let organisationMember = await prisma.organisationMember.findFirst({ + where: { + userId: user.id, + organisationId, + }, + }); + + if (!organisationMember) { + const organisationGroups = await prisma.organisationGroup.findMany({ + where: { + organisationId, + type: OrganisationGroupType.INTERNAL_ORGANISATION, + }, + }); + + await addUserToOrganisation({ + userId: user.id, + organisationId, + organisationGroups, + organisationMemberRole: OrganisationMemberRole.MEMBER, + bypassEmail: true, + }); + + organisationMember = await prisma.organisationMember.findFirstOrThrow({ + where: { + userId: user.id, + organisationId, + }, + }); + } + + return { user, organisationMemberId: organisationMember.id }; +}; + +const seedDocument = async ({ + spec, + index, + teamId, + senders, + templateSecondaryIds, +}: { + spec: DocumentSpec; + index: number; + teamId: number; + senders: User[]; + templateSecondaryIds: string[]; +}) => { + const sender = senders[spec.senderIndex]; + + if (!sender) { + throw new Error(`Sender index ${spec.senderIndex} is out of range`); + } + + const createdAt = atDaysAgo(spec.daysAgo); + + const baseTitle = DOCUMENT_TITLES[index % DOCUMENT_TITLES.length]; + const title = + index >= DOCUMENT_TITLES.length ? `${baseTitle} (${Math.floor(index / DOCUMENT_TITLES.length) + 1})` : baseTitle; + + const isSent = spec.status !== DocumentStatus.DRAFT; + const isCompleted = spec.status === DocumentStatus.COMPLETED; + + // Sent a few minutes to a couple of hours after creation. + const sentAt = notInFuture(new Date(createdAt.getTime() + randInt(5, 180) * MINUTE_MS)); + + // Completed 0.5-5 days after being sent. + const completedAt = notInFuture(new Date(sentAt.getTime() + randInt(12, 120) * HOUR_MS)); + + const templateSecondaryId = spec.templateIndex !== undefined ? templateSecondaryIds[spec.templateIndex] : undefined; + + const envelope = await seedBlankDocument(sender, teamId, { + createDocumentOptions: { + title, + status: spec.status, + visibility: spec.visibility, + createdAt, + updatedAt: isCompleted ? completedAt : isSent ? sentAt : createdAt, + ...(isCompleted ? { completedAt } : {}), + ...(templateSecondaryId ? { templateId: mapSecondaryIdToTemplateId(templateSecondaryId) } : {}), + }, + }); + + const recipientName = pick(RECIPIENT_NAMES); + + await prisma.recipient.create({ + data: { + envelopeId: envelope.id, + name: recipientName, + email: `${toEmailSlug(recipientName)}@example.com`, + token: nanoid(), + sendStatus: isSent ? SendStatus.SENT : SendStatus.NOT_SENT, + readStatus: isSent ? ReadStatus.OPENED : ReadStatus.NOT_OPENED, + signingStatus: isCompleted ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + signedAt: isCompleted ? completedAt : null, + }, + }); + + if (isSent) { + await prisma.documentAuditLog.create({ + data: { + envelopeId: envelope.id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + createdAt: sentAt, + userId: sender.id, + email: sender.email, + name: sender.name, + data: {}, + }, + }); + } + + return envelope; +}; + +const seedAnalyticsTeam = async ({ + spec, + admin, + organisationId, +}: { + spec: TeamSpec; + admin: User; + organisationId: string; +}): Promise => { + console.log(''); + console.log(`[SEEDING]: ${spec.name} (${spec.url})`); + + const wasDeleted = await deleteExistingTeam(spec.url, organisationId); + + if (wasDeleted) { + console.log(` Existing team "${spec.url}" deleted, recreating.`); + } else { + console.log(` No existing team "${spec.url}", creating.`); + } + + // inheritMembers: false attaches only the org admin/manager groups, so the admin + // is a team ADMIN and every other member is attached explicitly below. + await createTeam({ + userId: admin.id, + teamName: spec.name, + teamUrl: spec.url, + organisationId, + inheritMembers: false, + }); + + const team = await prisma.team.findUniqueOrThrow({ + where: { + url: spec.url, + }, + }); + + const members: User[] = []; + const membersToCreate: { organisationMemberId: string; teamRole: TeamMemberRole }[] = []; + + for (const memberSpec of spec.members) { + const email = `${spec.url}-${toEmailSlug(memberSpec.name)}@${MEMBER_EMAIL_DOMAIN}`; + + const { user, organisationMemberId } = await ensureOrganisationMember({ + name: memberSpec.name, + email, + organisationId, + }); + + members.push(user); + membersToCreate.push({ organisationMemberId, teamRole: memberSpec.role }); + } + + await createTeamMembers({ + userId: admin.id, + teamId: team.id, + membersToCreate, + }); + + console.log(` Members attached: ${members.length + 1} (incl. admin)`); + + const templateSecondaryIds: string[] = []; + + for (const templateSpec of spec.templates) { + const template = await seedBlankTemplate(admin, team.id, { + createTemplateOptions: { + title: templateSpec.title, + createdAt: atDaysAgo(BUSY_WINDOW_DAYS + 10), + }, + }); + + templateSecondaryIds.push(template.secondaryId); + } + + console.log(` Templates created: ${templateSecondaryIds.length}`); + + const senders: User[] = [admin, ...members]; + const documentSpecs = spec.buildDocuments(); + + let index = 0; + + for (const documentSpec of documentSpecs) { + await seedDocument({ spec: documentSpec, index, teamId: team.id, senders, templateSecondaryIds }); + index += 1; + } + + console.log(` Documents created: ${documentSpecs.length}`); + + const documentsByStatus = documentSpecs.reduce>((acc, documentSpec) => { + acc[documentSpec.status] = (acc[documentSpec.status] ?? 0) + 1; + return acc; + }, {}); + + const documentsByVisibility = documentSpecs.reduce>((acc, documentSpec) => { + acc[documentSpec.visibility] = (acc[documentSpec.visibility] ?? 0) + 1; + return acc; + }, {}); + + const documentsFromTemplates = documentSpecs.filter( + (documentSpec) => documentSpec.templateIndex !== undefined, + ).length; + + return { + url: team.url, + name: team.name, + memberCount: members.length + 1, + templateCount: templateSecondaryIds.length, + documentsByStatus, + documentsByVisibility, + documentsFromTemplates, + }; +}; + +/** + * Re-queries the database so the printed summary reflects what was actually stored + * rather than what the specs intended. + */ +const verifyTeam = async (teamUrl: string) => { + const team = await prisma.team.findUniqueOrThrow({ + where: { + url: teamUrl, + }, + }); + + const memberCount = await prisma.organisationMember.count({ + where: { + organisationGroupMembers: { + some: { + group: { + teamGroups: { + some: { + teamId: team.id, + }, + }, + }, + }, + }, + }, + }); + + const statusGroups = await prisma.envelope.groupBy({ + by: ['status'], + where: { + teamId: team.id, + type: EnvelopeType.DOCUMENT, + }, + _count: { + _all: true, + }, + }); + + const templateCount = await prisma.envelope.count({ + where: { + teamId: team.id, + type: EnvelopeType.TEMPLATE, + }, + }); + + const sentLogCount = await prisma.documentAuditLog.count({ + where: { + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + envelope: { + teamId: team.id, + type: EnvelopeType.DOCUMENT, + }, + }, + }); + + const fromTemplateCount = await prisma.envelope.count({ + where: { + teamId: team.id, + type: EnvelopeType.DOCUMENT, + templateId: { + not: null, + }, + }, + }); + + const last30Days = await prisma.envelope.count({ + where: { + teamId: team.id, + type: EnvelopeType.DOCUMENT, + createdAt: { + gte: new Date(NOW.getTime() - 30 * DAY_MS), + }, + }, + }); + + const last90Days = await prisma.envelope.count({ + where: { + teamId: team.id, + type: EnvelopeType.DOCUMENT, + createdAt: { + gte: new Date(NOW.getTime() - 90 * DAY_MS), + }, + }, + }); + + const byStatus = Object.fromEntries(statusGroups.map((group) => [group.status, group._count._all])); + + return { + teamUrl, + memberCount, + templateCount, + byStatus, + totalDocuments: statusGroups.reduce((sum, group) => sum + group._count._all, 0), + sentLogCount, + fromTemplateCount, + last30Days, + last90Days, + }; +}; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const seedAnalytics = async () => { + if (process.env.NODE_ENV === 'production') { + throw new Error('The analytics seed deletes and recreates teams and must not run in production.'); + } + + const { admin, organisation } = await getAdminUserAndOrganisation(); + + console.log(`[SEEDING]: Using organisation "${organisation.name}" (${organisation.url}) owned by ${admin.email}`); + + const summaries: SeededTeamSummary[] = []; + + for (const [index, spec] of TEAM_SPECS.entries()) { + // Reset the generator per team so each team's shape is independent of the others. + resetRandom(20260922 + index * 1000); + + summaries.push(await seedAnalyticsTeam({ spec, admin, organisationId: organisation.id })); + } + + console.log(''); + console.log('[SEEDING]: Verification (queried from database)'); + + for (const summary of summaries) { + const verified = await verifyTeam(summary.url); + + console.log(''); + console.log(` ${summary.name} - ${WEBAPP_URL}/t/${summary.url}/analytics`); + console.log(` Members: ${verified.memberCount}`); + console.log(` Templates: ${verified.templateCount}`); + console.log(` Documents: ${verified.totalDocuments} ${JSON.stringify(verified.byStatus)}`); + console.log(` Visibility: ${JSON.stringify(summary.documentsByVisibility)}`); + console.log(` DOCUMENT_SENT logs: ${verified.sentLogCount}`); + console.log(` From templates: ${verified.fromTemplateCount}`); + console.log(` Created last 30d: ${verified.last30Days}`); + console.log(` Created last 90d: ${verified.last90Days}`); + } + + console.log(''); + console.log('[SEEDING]: Done.'); + console.log(` Admin email: ${ADMIN_EMAIL}`); + console.log(` Admin password: ${ADMIN_PASSWORD}`); + + for (const summary of summaries) { + console.log(` ${WEBAPP_URL}/t/${summary.url}/analytics`); + } +}; + +const main = async () => { + try { + await seedAnalytics(); + } catch (err) { + console.error('[SEEDING]: Failed to seed analytics teams.'); + console.error(err); + process.exitCode = 1; + } finally { + await prisma.$disconnect(); + } +}; + +if (require.main === module) { + void main(); +} diff --git a/packages/trpc/server/organisation-router/get-organisation-analytics-documents-over-time.ts b/packages/trpc/server/organisation-router/get-organisation-analytics-documents-over-time.ts new file mode 100644 index 000000000..2c39d88b6 --- /dev/null +++ b/packages/trpc/server/organisation-router/get-organisation-analytics-documents-over-time.ts @@ -0,0 +1,32 @@ +import { getOrganisationAnalyticsDocumentsOverTime } from '@documenso/lib/server-only/organisation/get-organisation-analytics-documents-over-time'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetOrganisationAnalyticsDocumentsOverTimeRequestSchema, + ZGetOrganisationAnalyticsDocumentsOverTimeResponseSchema, +} from './get-organisation-analytics.types'; + +export const getOrganisationAnalyticsDocumentsOverTimeRoute = authenticatedProcedure + .input(ZGetOrganisationAnalyticsDocumentsOverTimeRequestSchema) + .output(ZGetOrganisationAnalyticsDocumentsOverTimeResponseSchema) + .query(async ({ input, ctx }) => { + const { organisationId, range, from, to, timezone } = input; + + ctx.logger.info({ + input: { + organisationId, + range, + from, + to, + }, + }); + + return await getOrganisationAnalyticsDocumentsOverTime({ + organisationId, + range, + from, + to, + timezone, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/organisation-router/get-organisation-analytics-overview.ts b/packages/trpc/server/organisation-router/get-organisation-analytics-overview.ts new file mode 100644 index 000000000..3137798ce --- /dev/null +++ b/packages/trpc/server/organisation-router/get-organisation-analytics-overview.ts @@ -0,0 +1,32 @@ +import { getOrganisationAnalyticsOverview } from '@documenso/lib/server-only/organisation/get-organisation-analytics-overview'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetOrganisationAnalyticsOverviewRequestSchema, + ZGetOrganisationAnalyticsOverviewResponseSchema, +} from './get-organisation-analytics.types'; + +export const getOrganisationAnalyticsOverviewRoute = authenticatedProcedure + .input(ZGetOrganisationAnalyticsOverviewRequestSchema) + .output(ZGetOrganisationAnalyticsOverviewResponseSchema) + .query(async ({ input, ctx }) => { + const { organisationId, range, from, to, timezone } = input; + + ctx.logger.info({ + input: { + organisationId, + range, + from, + to, + }, + }); + + return await getOrganisationAnalyticsOverview({ + organisationId, + range, + from, + to, + timezone, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/organisation-router/get-organisation-analytics-status-breakdown.ts b/packages/trpc/server/organisation-router/get-organisation-analytics-status-breakdown.ts new file mode 100644 index 000000000..8d4bcb9fe --- /dev/null +++ b/packages/trpc/server/organisation-router/get-organisation-analytics-status-breakdown.ts @@ -0,0 +1,32 @@ +import { getOrganisationAnalyticsStatusBreakdown } from '@documenso/lib/server-only/organisation/get-organisation-analytics-status-breakdown'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetOrganisationAnalyticsStatusBreakdownRequestSchema, + ZGetOrganisationAnalyticsStatusBreakdownResponseSchema, +} from './get-organisation-analytics.types'; + +export const getOrganisationAnalyticsStatusBreakdownRoute = authenticatedProcedure + .input(ZGetOrganisationAnalyticsStatusBreakdownRequestSchema) + .output(ZGetOrganisationAnalyticsStatusBreakdownResponseSchema) + .query(async ({ input, ctx }) => { + const { organisationId, range, from, to, timezone } = input; + + ctx.logger.info({ + input: { + organisationId, + range, + from, + to, + }, + }); + + return await getOrganisationAnalyticsStatusBreakdown({ + organisationId, + range, + from, + to, + timezone, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/organisation-router/get-organisation-analytics-team-activity.ts b/packages/trpc/server/organisation-router/get-organisation-analytics-team-activity.ts new file mode 100644 index 000000000..faf315c2c --- /dev/null +++ b/packages/trpc/server/organisation-router/get-organisation-analytics-team-activity.ts @@ -0,0 +1,32 @@ +import { getOrganisationAnalyticsTeamActivity } from '@documenso/lib/server-only/organisation/get-organisation-analytics-team-activity'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetOrganisationAnalyticsTeamActivityRequestSchema, + ZGetOrganisationAnalyticsTeamActivityResponseSchema, +} from './get-organisation-analytics.types'; + +export const getOrganisationAnalyticsTeamActivityRoute = authenticatedProcedure + .input(ZGetOrganisationAnalyticsTeamActivityRequestSchema) + .output(ZGetOrganisationAnalyticsTeamActivityResponseSchema) + .query(async ({ input, ctx }) => { + const { organisationId, range, from, to, timezone } = input; + + ctx.logger.info({ + input: { + organisationId, + range, + from, + to, + }, + }); + + return await getOrganisationAnalyticsTeamActivity({ + organisationId, + range, + from, + to, + timezone, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/organisation-router/get-organisation-analytics-template-usage.ts b/packages/trpc/server/organisation-router/get-organisation-analytics-template-usage.ts new file mode 100644 index 000000000..5517c1172 --- /dev/null +++ b/packages/trpc/server/organisation-router/get-organisation-analytics-template-usage.ts @@ -0,0 +1,34 @@ +import { getOrganisationAnalyticsTemplateUsage } from '@documenso/lib/server-only/organisation/get-organisation-analytics-template-usage'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetOrganisationAnalyticsTemplateUsageRequestSchema, + ZGetOrganisationAnalyticsTemplateUsageResponseSchema, +} from './get-organisation-analytics.types'; + +export const getOrganisationAnalyticsTemplateUsageRoute = authenticatedProcedure + .input(ZGetOrganisationAnalyticsTemplateUsageRequestSchema) + .output(ZGetOrganisationAnalyticsTemplateUsageResponseSchema) + .query(async ({ input, ctx }) => { + const { organisationId, range, from, to, timezone, limit } = input; + + ctx.logger.info({ + input: { + organisationId, + range, + from, + to, + limit, + }, + }); + + return await getOrganisationAnalyticsTemplateUsage({ + organisationId, + range, + from, + to, + timezone, + limit, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/organisation-router/get-organisation-analytics.types.ts b/packages/trpc/server/organisation-router/get-organisation-analytics.types.ts new file mode 100644 index 000000000..152ffb59e --- /dev/null +++ b/packages/trpc/server/organisation-router/get-organisation-analytics.types.ts @@ -0,0 +1,161 @@ +import { z } from 'zod'; + +import { + ZAnalyticsRangeFieldsSchema, + ZGetTeamAnalyticsDocumentsOverTimeResponseSchema, + ZGetTeamAnalyticsStatusBreakdownResponseSchema, + ZTeamAnalyticsResolvedRangeSchema, +} from '../team-router/get-team-analytics.types'; + +/** + * Base request shared by every organisation analytics procedure. + * + * Organisation analytics are restricted to organisation ADMINs. The organisation + * ADMIN role authorises organisation-wide visibility: the internal ADMIN group is + * attached to every team by `createTeam` and cannot be detached, so every document + * in the organisation is in scope and no per-document visibility filtering is applied. + */ +export const ZOrganisationAnalyticsRequestSchema = ZAnalyticsRangeFieldsSchema.extend({ + organisationId: z.string().min(1), +}); + +export type TOrganisationAnalyticsRequest = z.infer; + +const ZCountSchema = z.number().int().nonnegative(); + +const ZAnalyticsTeamSchema = z.object({ + id: z.number().int().positive(), + name: z.string(), + url: z.string(), + avatarImageId: z.string().nullable(), +}); + +// ----------------------------------------------------------------------------- +// organisation.analytics.getOverview +// ----------------------------------------------------------------------------- + +export const ZGetOrganisationAnalyticsOverviewRequestSchema = ZOrganisationAnalyticsRequestSchema; + +export const ZGetOrganisationAnalyticsOverviewResponseSchema = z.object({ + range: ZTeamAnalyticsResolvedRangeSchema, + /** Documents whose DOCUMENT_SENT event falls inside the window. */ + sent: z.object({ + current: ZCountSchema, + previous: ZCountSchema, + }), + /** Of documents sent in the window, how many are currently COMPLETED. */ + completionRate: z.object({ + completed: ZCountSchema, + sent: ZCountSchema, + rate: z.number().min(0).max(100).nullable(), + previousRate: z.number().min(0).max(100).nullable(), + }), + teams: z.object({ + /** Total number of teams in the organisation. */ + total: ZCountSchema, + /** Teams with at least one document sent in the window. */ + active: ZCountSchema, + }), +}); + +export type TGetOrganisationAnalyticsOverviewRequest = z.infer; +export type TGetOrganisationAnalyticsOverviewResponse = z.infer; + +// ----------------------------------------------------------------------------- +// organisation.analytics.getDocumentsOverTime +// ----------------------------------------------------------------------------- + +export const ZGetOrganisationAnalyticsDocumentsOverTimeRequestSchema = ZOrganisationAnalyticsRequestSchema; + +export const ZGetOrganisationAnalyticsDocumentsOverTimeResponseSchema = + ZGetTeamAnalyticsDocumentsOverTimeResponseSchema; + +export type TGetOrganisationAnalyticsDocumentsOverTimeRequest = z.infer< + typeof ZGetOrganisationAnalyticsDocumentsOverTimeRequestSchema +>; +export type TGetOrganisationAnalyticsDocumentsOverTimeResponse = z.infer< + typeof ZGetOrganisationAnalyticsDocumentsOverTimeResponseSchema +>; + +// ----------------------------------------------------------------------------- +// organisation.analytics.getStatusBreakdown +// ----------------------------------------------------------------------------- + +export const ZGetOrganisationAnalyticsStatusBreakdownRequestSchema = ZOrganisationAnalyticsRequestSchema; + +export const ZGetOrganisationAnalyticsStatusBreakdownResponseSchema = ZGetTeamAnalyticsStatusBreakdownResponseSchema; + +export type TGetOrganisationAnalyticsStatusBreakdownRequest = z.infer< + typeof ZGetOrganisationAnalyticsStatusBreakdownRequestSchema +>; +export type TGetOrganisationAnalyticsStatusBreakdownResponse = z.infer< + typeof ZGetOrganisationAnalyticsStatusBreakdownResponseSchema +>; + +// ----------------------------------------------------------------------------- +// organisation.analytics.getTemplateUsage +// ----------------------------------------------------------------------------- + +export const ZGetOrganisationAnalyticsTemplateUsageRequestSchema = ZOrganisationAnalyticsRequestSchema.extend({ + limit: z.number().int().min(1).max(20).default(5), +}); + +/** Templates across the organisation ranked by documents created from them inside the window. */ +export const ZGetOrganisationAnalyticsTemplateUsageResponseSchema = z.object({ + range: ZTeamAnalyticsResolvedRangeSchema, + templates: z.array( + z.object({ + /** Legacy numeric template id (Envelope.templateId on documents). */ + id: z.number().int().positive(), + /** Envelope id of the template, null when the template was deleted. */ + envelopeId: z.string().nullable(), + title: z.string().nullable(), + updatedAt: z.date().nullable(), + /** Team the template belongs to, null when the template was deleted. */ + team: ZAnalyticsTeamSchema.nullable(), + count: ZCountSchema, + }), + ), +}); + +export type TGetOrganisationAnalyticsTemplateUsageRequest = z.infer< + typeof ZGetOrganisationAnalyticsTemplateUsageRequestSchema +>; +export type TGetOrganisationAnalyticsTemplateUsageResponse = z.infer< + typeof ZGetOrganisationAnalyticsTemplateUsageResponseSchema +>; + +// ----------------------------------------------------------------------------- +// organisation.analytics.getTeamActivity +// ----------------------------------------------------------------------------- + +export const ZGetOrganisationAnalyticsTeamActivityRequestSchema = ZOrganisationAnalyticsRequestSchema; + +/** + * Per-team activity for every team in the organisation. Teams with no activity + * are included with zeros. Sorted by sent desc, then name asc. + */ +export const ZGetOrganisationAnalyticsTeamActivityResponseSchema = z.object({ + range: ZTeamAnalyticsResolvedRangeSchema, + teams: z.array( + ZAnalyticsTeamSchema.extend({ + /** Documents sent by this team inside the window. */ + sent: ZCountSchema, + /** Of `sent`, currently COMPLETED. */ + completed: ZCountSchema, + /** Of `sent`, currently PENDING. */ + pending: ZCountSchema, + /** 0-100, null when `sent` is 0. */ + completionRate: z.number().min(0).max(100).nullable(), + /** Most recent send inside the window, null when none. */ + lastActiveAt: z.date().nullable(), + }), + ), +}); + +export type TGetOrganisationAnalyticsTeamActivityRequest = z.infer< + typeof ZGetOrganisationAnalyticsTeamActivityRequestSchema +>; +export type TGetOrganisationAnalyticsTeamActivityResponse = z.infer< + typeof ZGetOrganisationAnalyticsTeamActivityResponseSchema +>; diff --git a/packages/trpc/server/organisation-router/router.ts b/packages/trpc/server/organisation-router/router.ts index e0c1bbba4..dfac4c567 100644 --- a/packages/trpc/server/organisation-router/router.ts +++ b/packages/trpc/server/organisation-router/router.ts @@ -13,6 +13,11 @@ import { findOrganisationGroupsRoute } from './find-organisation-groups'; import { findOrganisationMemberInvitesRoute } from './find-organisation-member-invites'; import { findOrganisationMembersRoute } from './find-organisation-members'; import { getOrganisationRoute } from './get-organisation'; +import { getOrganisationAnalyticsDocumentsOverTimeRoute } from './get-organisation-analytics-documents-over-time'; +import { getOrganisationAnalyticsOverviewRoute } from './get-organisation-analytics-overview'; +import { getOrganisationAnalyticsStatusBreakdownRoute } from './get-organisation-analytics-status-breakdown'; +import { getOrganisationAnalyticsTeamActivityRoute } from './get-organisation-analytics-team-activity'; +import { getOrganisationAnalyticsTemplateUsageRoute } from './get-organisation-analytics-template-usage'; import { getOrganisationMemberInvitesRoute } from './get-organisation-member-invites'; import { getOrganisationQuotaFlagsRoute } from './get-organisation-quota-flags'; import { getOrganisationSessionRoute } from './get-organisation-session'; @@ -33,6 +38,13 @@ export const organisationRouter = router({ update: updateOrganisationRoute, delete: deleteOrganisationRoute, leave: leaveOrganisationRoute, + analytics: { + getOverview: getOrganisationAnalyticsOverviewRoute, + getDocumentsOverTime: getOrganisationAnalyticsDocumentsOverTimeRoute, + getStatusBreakdown: getOrganisationAnalyticsStatusBreakdownRoute, + getTemplateUsage: getOrganisationAnalyticsTemplateUsageRoute, + getTeamActivity: getOrganisationAnalyticsTeamActivityRoute, + }, member: { find: findOrganisationMembersRoute, update: updateOrganisationMemberRoute, diff --git a/packages/trpc/server/team-router/get-team-analytics-documents-over-time.ts b/packages/trpc/server/team-router/get-team-analytics-documents-over-time.ts new file mode 100644 index 000000000..ea5d342c2 --- /dev/null +++ b/packages/trpc/server/team-router/get-team-analytics-documents-over-time.ts @@ -0,0 +1,33 @@ +import { getTeamAnalyticsDocumentsOverTime } from '@documenso/lib/server-only/team/get-team-analytics-documents-over-time'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetTeamAnalyticsDocumentsOverTimeRequestSchema, + ZGetTeamAnalyticsDocumentsOverTimeResponseSchema, +} from './get-team-analytics.types'; + +export const getTeamAnalyticsDocumentsOverTimeRoute = authenticatedProcedure + .input(ZGetTeamAnalyticsDocumentsOverTimeRequestSchema) + .output(ZGetTeamAnalyticsDocumentsOverTimeResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, range, from, to, timezone } = input; + + ctx.logger.info({ + input: { + teamId, + range, + from, + to, + }, + }); + + return await getTeamAnalyticsDocumentsOverTime({ + teamId, + range, + from, + to, + timezone, + userId: ctx.user.id, + userEmail: ctx.user.email, + }); + }); diff --git a/packages/trpc/server/team-router/get-team-analytics-member-activity.ts b/packages/trpc/server/team-router/get-team-analytics-member-activity.ts new file mode 100644 index 000000000..50c5b773c --- /dev/null +++ b/packages/trpc/server/team-router/get-team-analytics-member-activity.ts @@ -0,0 +1,33 @@ +import { getTeamAnalyticsMemberActivity } from '@documenso/lib/server-only/team/get-team-analytics-member-activity'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetTeamAnalyticsMemberActivityRequestSchema, + ZGetTeamAnalyticsMemberActivityResponseSchema, +} from './get-team-analytics.types'; + +export const getTeamAnalyticsMemberActivityRoute = authenticatedProcedure + .input(ZGetTeamAnalyticsMemberActivityRequestSchema) + .output(ZGetTeamAnalyticsMemberActivityResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, range, from, to, timezone } = input; + + ctx.logger.info({ + input: { + teamId, + range, + from, + to, + }, + }); + + return await getTeamAnalyticsMemberActivity({ + teamId, + range, + from, + to, + timezone, + userId: ctx.user.id, + userEmail: ctx.user.email, + }); + }); diff --git a/packages/trpc/server/team-router/get-team-analytics-overview.ts b/packages/trpc/server/team-router/get-team-analytics-overview.ts new file mode 100644 index 000000000..e1668be4c --- /dev/null +++ b/packages/trpc/server/team-router/get-team-analytics-overview.ts @@ -0,0 +1,33 @@ +import { getTeamAnalyticsOverview } from '@documenso/lib/server-only/team/get-team-analytics-overview'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetTeamAnalyticsOverviewRequestSchema, + ZGetTeamAnalyticsOverviewResponseSchema, +} from './get-team-analytics.types'; + +export const getTeamAnalyticsOverviewRoute = authenticatedProcedure + .input(ZGetTeamAnalyticsOverviewRequestSchema) + .output(ZGetTeamAnalyticsOverviewResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, range, from, to, timezone } = input; + + ctx.logger.info({ + input: { + teamId, + range, + from, + to, + }, + }); + + return await getTeamAnalyticsOverview({ + teamId, + range, + from, + to, + timezone, + userId: ctx.user.id, + userEmail: ctx.user.email, + }); + }); diff --git a/packages/trpc/server/team-router/get-team-analytics-status-breakdown.ts b/packages/trpc/server/team-router/get-team-analytics-status-breakdown.ts new file mode 100644 index 000000000..c21a53ca7 --- /dev/null +++ b/packages/trpc/server/team-router/get-team-analytics-status-breakdown.ts @@ -0,0 +1,33 @@ +import { getTeamAnalyticsStatusBreakdown } from '@documenso/lib/server-only/team/get-team-analytics-status-breakdown'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetTeamAnalyticsStatusBreakdownRequestSchema, + ZGetTeamAnalyticsStatusBreakdownResponseSchema, +} from './get-team-analytics.types'; + +export const getTeamAnalyticsStatusBreakdownRoute = authenticatedProcedure + .input(ZGetTeamAnalyticsStatusBreakdownRequestSchema) + .output(ZGetTeamAnalyticsStatusBreakdownResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, range, from, to, timezone } = input; + + ctx.logger.info({ + input: { + teamId, + range, + from, + to, + }, + }); + + return await getTeamAnalyticsStatusBreakdown({ + teamId, + range, + from, + to, + timezone, + userId: ctx.user.id, + userEmail: ctx.user.email, + }); + }); diff --git a/packages/trpc/server/team-router/get-team-analytics-template-usage.ts b/packages/trpc/server/team-router/get-team-analytics-template-usage.ts new file mode 100644 index 000000000..dfc6e5c14 --- /dev/null +++ b/packages/trpc/server/team-router/get-team-analytics-template-usage.ts @@ -0,0 +1,35 @@ +import { getTeamAnalyticsTemplateUsage } from '@documenso/lib/server-only/team/get-team-analytics-template-usage'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetTeamAnalyticsTemplateUsageRequestSchema, + ZGetTeamAnalyticsTemplateUsageResponseSchema, +} from './get-team-analytics.types'; + +export const getTeamAnalyticsTemplateUsageRoute = authenticatedProcedure + .input(ZGetTeamAnalyticsTemplateUsageRequestSchema) + .output(ZGetTeamAnalyticsTemplateUsageResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, range, from, to, timezone, limit } = input; + + ctx.logger.info({ + input: { + teamId, + range, + from, + to, + limit, + }, + }); + + return await getTeamAnalyticsTemplateUsage({ + teamId, + range, + from, + to, + timezone, + limit, + userId: ctx.user.id, + userEmail: ctx.user.email, + }); + }); diff --git a/packages/trpc/server/team-router/get-team-analytics.types.ts b/packages/trpc/server/team-router/get-team-analytics.types.ts new file mode 100644 index 000000000..efdad8c45 --- /dev/null +++ b/packages/trpc/server/team-router/get-team-analytics.types.ts @@ -0,0 +1,198 @@ +import { IANAZone } from 'luxon'; +import { z } from 'zod'; + +/** Preset date ranges supported by analytics, plus a custom [from, to] window. */ +export const ZTeamAnalyticsRangeSchema = z.enum(['7d', '30d', '90d', '12m', 'custom']); + +export type TTeamAnalyticsRange = z.infer; + +/** Calendar date in the request timezone, formatted yyyy-MM-dd. */ +export const ZAnalyticsDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected yyyy-MM-dd'); + +/** How far back a custom range may start: one year and one day before today. */ +export const ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK = { years: 1, days: 1 } as const; + +/** + * Range fields shared by team and organisation analytics requests. + * + * `from` and `to` are inclusive calendar dates and are required when `range` is + * `custom`, ignored otherwise. Combined validation happens in the range resolver so + * this object stays extendable. + */ +export const ZAnalyticsRangeFieldsSchema = z.object({ + range: ZTeamAnalyticsRangeSchema.default('30d'), + from: ZAnalyticsDateSchema.optional(), + to: ZAnalyticsDateSchema.optional(), + /** IANA timezone used to resolve day boundaries and buckets. */ + timezone: z + .string() + .min(1) + .refine((timezone) => IANAZone.isValidZone(timezone), { message: 'Invalid timezone' }) + .default('UTC'), +}); + +export type TAnalyticsRangeFields = z.infer; + +/** Base request shared by every team analytics procedure. */ +export const ZTeamAnalyticsRequestSchema = ZAnalyticsRangeFieldsSchema.extend({ + teamId: z.number().int().positive(), +}); + +export type TTeamAnalyticsRequest = z.infer; + +const ZCountSchema = z.number().int().nonnegative(); + +/** Resolved half-open date window, plus the equally sized window immediately before it. */ +export const ZTeamAnalyticsResolvedRangeSchema = z.object({ + range: ZTeamAnalyticsRangeSchema, + /** Inclusive calendar bounds of the window in the request timezone, yyyy-MM-dd. */ + from: ZAnalyticsDateSchema, + to: ZAnalyticsDateSchema, + timezone: z.string(), + start: z.date(), + end: z.date(), + previousStart: z.date(), + previousEnd: z.date(), + bucket: z.enum(['day', 'month']), +}); + +export type TTeamAnalyticsResolvedRange = z.infer; + +// ----------------------------------------------------------------------------- +// team.analytics.getOverview +// ----------------------------------------------------------------------------- + +export const ZGetTeamAnalyticsOverviewRequestSchema = ZTeamAnalyticsRequestSchema; + +export const ZGetTeamAnalyticsOverviewResponseSchema = z.object({ + range: ZTeamAnalyticsResolvedRangeSchema, + /** Documents whose first DOCUMENT_SENT event falls inside the window. */ + sent: z.object({ + current: ZCountSchema, + previous: ZCountSchema, + }), + /** Of documents sent in the window, how many are currently COMPLETED. */ + completionRate: z.object({ + completed: ZCountSchema, + sent: ZCountSchema, + /** 0-100, null when nothing was sent. */ + rate: z.number().min(0).max(100).nullable(), + /** Previous window rate, null when nothing was sent then. */ + previousRate: z.number().min(0).max(100).nullable(), + }), + members: z.object({ + /** Total number of users who are members of the team. */ + total: ZCountSchema, + /** Distinct team members who sent at least one visible document in the window. */ + active: ZCountSchema, + }), +}); + +export type TGetTeamAnalyticsOverviewRequest = z.infer; +export type TGetTeamAnalyticsOverviewResponse = z.infer; + +// ----------------------------------------------------------------------------- +// team.analytics.getDocumentsOverTime +// ----------------------------------------------------------------------------- + +export const ZGetTeamAnalyticsDocumentsOverTimeRequestSchema = ZTeamAnalyticsRequestSchema; + +export const ZGetTeamAnalyticsDocumentsOverTimeResponseSchema = z.object({ + range: ZTeamAnalyticsResolvedRangeSchema, + total: ZCountSchema, + /** Zero-filled, ordered ascending. `date` is the bucket start as yyyy-MM-dd in the request timezone. */ + points: z.array( + z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + count: ZCountSchema, + }), + ), +}); + +export type TGetTeamAnalyticsDocumentsOverTimeRequest = z.infer; +export type TGetTeamAnalyticsDocumentsOverTimeResponse = z.infer< + typeof ZGetTeamAnalyticsDocumentsOverTimeResponseSchema +>; + +// ----------------------------------------------------------------------------- +// team.analytics.getStatusBreakdown +// ----------------------------------------------------------------------------- + +export const ZGetTeamAnalyticsStatusBreakdownRequestSchema = ZTeamAnalyticsRequestSchema; + +/** Current status of documents created inside the window. */ +export const ZGetTeamAnalyticsStatusBreakdownResponseSchema = z.object({ + range: ZTeamAnalyticsResolvedRangeSchema, + total: ZCountSchema, + draft: ZCountSchema, + pending: ZCountSchema, + completed: ZCountSchema, + rejected: ZCountSchema, + cancelled: ZCountSchema, +}); + +export type TGetTeamAnalyticsStatusBreakdownRequest = z.infer; +export type TGetTeamAnalyticsStatusBreakdownResponse = z.infer; + +// ----------------------------------------------------------------------------- +// team.analytics.getTemplateUsage +// ----------------------------------------------------------------------------- + +export const ZGetTeamAnalyticsTemplateUsageRequestSchema = ZTeamAnalyticsRequestSchema.extend({ + limit: z.number().int().min(1).max(20).default(5), +}); + +/** Templates ranked by the number of visible documents created from them inside the window. */ +export const ZGetTeamAnalyticsTemplateUsageResponseSchema = z.object({ + range: ZTeamAnalyticsResolvedRangeSchema, + templates: z.array( + z.object({ + /** Legacy numeric template id (Envelope.templateId on documents). */ + id: z.number().int().positive(), + /** Envelope id of the template, null when the template was deleted or is not visible to the caller. */ + envelopeId: z.string().nullable(), + title: z.string().nullable(), + updatedAt: z.date().nullable(), + count: ZCountSchema, + }), + ), +}); + +export type TGetTeamAnalyticsTemplateUsageRequest = z.infer; +export type TGetTeamAnalyticsTemplateUsageResponse = z.infer; + +// ----------------------------------------------------------------------------- +// team.analytics.getMemberActivity +// ----------------------------------------------------------------------------- + +export const ZGetTeamAnalyticsMemberActivityRequestSchema = ZTeamAnalyticsRequestSchema; + +/** + * Per-member activity for every current team member, scoped to documents the + * caller can see. Members with no visible activity are included with zeros. + * Sorted by sent desc, then name asc. + */ +export const ZGetTeamAnalyticsMemberActivityResponseSchema = z.object({ + range: ZTeamAnalyticsResolvedRangeSchema, + members: z.array( + z.object({ + userId: z.number().int().positive(), + name: z.string().nullable(), + email: z.string(), + avatarImageId: z.string().nullable(), + /** Visible documents first sent by this member inside the window. */ + sent: ZCountSchema, + /** Of `sent`, currently COMPLETED. */ + completed: ZCountSchema, + /** Of `sent`, currently PENDING. */ + pending: ZCountSchema, + /** 0-100, null when `sent` is 0. */ + completionRate: z.number().min(0).max(100).nullable(), + /** Most recent send inside the window, null when none. */ + lastActiveAt: z.date().nullable(), + }), + ), +}); + +export type TGetTeamAnalyticsMemberActivityRequest = z.infer; +export type TGetTeamAnalyticsMemberActivityResponse = z.infer; diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts index aa9cb1819..b3f5843e0 100644 --- a/packages/trpc/server/team-router/router.ts +++ b/packages/trpc/server/team-router/router.ts @@ -16,6 +16,11 @@ import { findTeamGroupsRoute } from './find-team-groups'; import { findTeamMembersRoute } from './find-team-members'; import { findTeamsRoute } from './find-teams'; import { getTeamRoute } from './get-team'; +import { getTeamAnalyticsDocumentsOverTimeRoute } from './get-team-analytics-documents-over-time'; +import { getTeamAnalyticsMemberActivityRoute } from './get-team-analytics-member-activity'; +import { getTeamAnalyticsOverviewRoute } from './get-team-analytics-overview'; +import { getTeamAnalyticsStatusBreakdownRoute } from './get-team-analytics-status-breakdown'; +import { getTeamAnalyticsTemplateUsageRoute } from './get-team-analytics-template-usage'; import { getTeamMembersRoute } from './get-team-members'; import { ZCreateTeamEmailVerificationMutationSchema, @@ -36,6 +41,13 @@ export const teamRouter = router({ create: createTeamRoute, update: updateTeamRoute, delete: deleteTeamRoute, + analytics: { + getOverview: getTeamAnalyticsOverviewRoute, + getDocumentsOverTime: getTeamAnalyticsDocumentsOverTimeRoute, + getStatusBreakdown: getTeamAnalyticsStatusBreakdownRoute, + getTemplateUsage: getTeamAnalyticsTemplateUsageRoute, + getMemberActivity: getTeamAnalyticsMemberActivityRoute, + }, member: { find: findTeamMembersRoute, getMany: getTeamMembersRoute,