Merge branch 'main' into fix/signature-pad-dialog-reset-on-cancel

This commit is contained in:
Catalin Pit
2026-09-28 16:51:38 +03:00
committed by GitHub
59 changed files with 7292 additions and 33 deletions
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
description: t`The file is not a valid PDF or is password protected.`,
variant: 'destructive',
});
}
@@ -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<unknown>;
/** 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<string | null>(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 (
<Card className={className} data-testid={testId('activity')}>
<CardHeader className="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col space-y-1.5">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
{data !== undefined && rows.length > 0 && (
<p className="shrink-0 text-muted-foreground text-sm tabular-nums" data-testid={testId('summary')}>
{renderSummary(rows.length, activeCount)}
</p>
)}
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || data === undefined ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 4 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-10" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="h-4 w-28" />
<Skeleton className="hidden h-4 w-20 md:block" />
</li>
))}
</ul>
) : rows.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<EmptyIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">{emptyLabel}</p>
</div>
) : (
<div className="flex flex-col gap-y-3">
<div className="relative sm:max-w-xs">
<SearchIcon
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
className="pl-9"
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
data-testid={testId('search')}
/>
</div>
{filteredRows.length === 0 ? (
<p className="py-8 text-center text-muted-foreground text-sm" data-testid={testId('no-results')}>
{noSearchResultsLabel}
</p>
) : (
<>
{/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */}
<div className="-mx-6">
<Table className="[&_td]:px-2 md:[&_td]:px-4 [&_th]:px-2 md:[&_th]:px-4">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={FIRST_CELL_CLASS}>{columnLabel}</TableHead>
<TableHead className="text-right">
<Trans>Sent</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Completed</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Pending</Trans>
</TableHead>
<TableHead className={cn('text-right', LAST_CELL_ON_MOBILE_CLASS)}>
<Trans>Completion rate</Trans>
</TableHead>
<TableHead className={cn('hidden text-right md:table-cell', LAST_CELL_CLASS)}>
<Trans>Last active</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<ActivityRow key={row.key} row={row} locale={i18n.locale} testIdPrefix={testIdPrefix} />
))}
</TableBody>
</Table>
</div>
{hasHiddenRows && (
<div className="flex items-center justify-between gap-x-4 border-border border-t pt-3">
<p className="text-muted-foreground text-sm" data-testid={testId('showing')}>
{renderShowing(visibleRows.length, filteredRows.length)}
</p>
<Button
variant="ghost"
size="sm"
className="-mr-2"
onClick={() => setExpandedRangeKey(rangeKey)}
data-testid={testId('show-all')}
>
<Trans>Show all</Trans>
</Button>
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
};
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 = (
<span role="img" aria-label={_(msg`Not available`)}>
—
</span>
);
/**
* 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<HTMLTableRowElement>) => {
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 (
<TableRow
className={cn(row.href && 'cursor-pointer')}
onClick={handleRowClick}
data-testid={testId('row')}
data-active={isActive ? 'true' : 'false'}
>
{/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */}
<TableCell truncate={false} className={cn('w-full max-w-0', FIRST_CELL_CLASS)}>
<div className="flex min-w-0 items-center gap-x-3">
<Avatar className="h-9 w-9 shrink-0">
{row.avatar.imageId && <AvatarImage src={formatAvatarUrl(row.avatar.imageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{row.avatar.fallback}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
{row.href ? (
<Link
to={row.href}
className={cn(
'truncate font-medium text-sm hover:underline',
isActive ? 'text-foreground' : 'text-foreground/80',
)}
>
{row.title}
</Link>
) : (
<span className={cn('truncate font-medium text-sm', isActive ? 'text-foreground' : 'text-foreground/80')}>
{row.title}
</span>
)}
{row.subtitle && <span className="truncate text-muted-foreground text-xs">{row.subtitle}</span>}
</div>
</div>
</TableCell>
<TableCell className={secondaryNumberClass} data-testid={testId('sent')}>
{row.sent.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', primaryNumberClass)} data-testid={testId('completed')}>
{row.completed.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', secondaryNumberClass)} data-testid={testId('pending')}>
{row.pending.toLocaleString(locale)}
</TableCell>
<TableCell className={cn(primaryNumberClass, LAST_CELL_ON_MOBILE_CLASS)} data-testid={testId('completion-rate')}>
{row.completionRate === null ? (
notAvailable
) : (
<div className="flex items-center justify-end gap-x-2">
<div className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:block" aria-hidden="true">
<div className="h-full rounded-full bg-primary" style={{ width: `${row.completionRate}%` }} />
</div>
<span className="w-9 text-right">{Math.round(row.completionRate)}%</span>
</div>
)}
</TableCell>
<TableCell
className={cn('hidden md:table-cell', secondaryNumberClass, LAST_CELL_CLASS)}
data-testid={testId('last-active')}
>
{row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)}
</TableCell>
</TableRow>
);
};
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';
@@ -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<TGetTeamAnalyticsDocumentsOverTimeResponse>;
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 (
<Card className={cn('flex flex-col', className)} data-testid="analytics-documents-over-time">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Documents created</Trans>
</CardTitle>
<CardDescription>{bucket === 'month' ? <Trans>Monthly</Trans> : <Trans>Daily</Trans>}</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap" data-testid="analytics-documents-over-time-total">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
{/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */}
<CardContent className="flex flex-1 flex-col justify-center">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<Skeleton className="w-full" style={{ height: CHART_HEIGHT }} />
) : data.total === 0 ? (
<div
className="flex flex-col items-center justify-center gap-y-3 text-center"
style={{ height: CHART_HEIGHT }}
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<BarChart3Icon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="text-muted-foreground text-sm">
<Trans>No documents created in this period</Trans>
</p>
</div>
) : (
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
<BarChart data={data.points} margin={{ top: 8, right: 0, bottom: 0, left: 0 }} barCategoryGap="20%">
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
interval={tickInterval}
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={(value: string) => formatTickLabel(value, bucket, i18n.locale)}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={36}
tickCount={4}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
/>
<Tooltip
content={<DocumentsOverTimeTooltip bucket={bucket} locale={i18n.locale} />}
cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={28}
background={{ fill: 'hsl(var(--muted) / 0.5)', radius: 4 }}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
};
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 (
<div className="rounded-md border border-border bg-popover px-3 py-2 text-popover-foreground text-sm shadow-md">
<p className="text-muted-foreground text-xs">{formatTooltipLabel(point.date, bucket, locale)}</p>
<p className="mt-0.5 font-medium tabular-nums">
<Plural value={count} one="# document" other="# documents" />
</p>
</div>
);
};
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);
};
@@ -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 (
<div role="status" aria-live="polite" data-testid="analytics-loading">
<SpinnerBox />
<span className="sr-only">
<Trans>Loading analytics</Trans>
</span>
</div>
);
};
@@ -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 (
<Alert variant="neutral" padding="tight" className="mt-6" data-testid="analytics-no-activity">
<AlertDescription className="flex min-h-9 flex-wrap items-center justify-between gap-x-4 gap-y-2">
<span className="flex items-center gap-x-2">
<InfoIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>
{_(ANALYTICS_NO_ACTIVITY_LABELS[range])}
{canWidenRange && (
<>
{' '}
<Trans>Try a longer range.</Trans>
</>
)}
</span>
</span>
{canWidenRange && (
<Button variant="ghost" size="sm" className="-mr-2" onClick={onShowLastYear}>
<Trans>Show last 12 months</Trans>
</Button>
)}
</AlertDescription>
</Alert>
);
};
@@ -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<TGetTeamAnalyticsOverviewResponse, 'sent' | 'completionRate'>;
/**
* 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<TData> = {
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<TData extends AnalyticsOverviewData> = {
query: AnalyticsQueryResult<TData>;
entity: AnalyticsOverviewEntityCard<TData>;
};
export const AnalyticsOverviewCards = <TData extends AnalyticsOverviewData>({
query,
entity,
}: AnalyticsOverviewCardsProps<TData>) => {
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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<AnalyticsStatCard
{...sharedProps}
icon={SendIcon}
title={<Trans>Documents sent</Trans>}
value={data ? formatNumber(data.sent.current) : null}
badge={data ? <SentDeltaBadge current={data.sent.current} previous={data.sent.previous} /> : null}
description={<Trans>vs. previous period</Trans>}
testId="analytics-sent"
/>
<AnalyticsStatCard
{...sharedProps}
icon={CircleCheckIcon}
title={<Trans>Completion rate</Trans>}
value={data ? formatRate(data.completionRate.rate) : null}
badge={
data ? (
<CompletionRateDeltaBadge rate={data.completionRate.rate} previousRate={data.completionRate.previousRate} />
) : null
}
description={<Trans>of sent documents completed</Trans>}
testId="analytics-completion-rate"
/>
<AnalyticsStatCard
{...sharedProps}
icon={entity.icon}
title={entity.title}
value={entityCounts ? `${formatNumber(entityCounts.active)}/${formatNumber(entityCounts.total)}` : null}
description={
entityCounts ? (
<Trans>
{formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '}
inactive
</Trans>
) : null
}
testId={entity.testId}
/>
</div>
);
};
type SentDeltaBadgeProps = {
current: number;
previous: number;
};
const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => {
if (previous === 0 && current === 0) {
return null;
}
if (previous === 0) {
return (
<DeltaBadge tone="new" testId="analytics-sent-delta">
<Trans>New</Trans>
</DeltaBadge>
);
}
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 (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-sent-delta">
{label}
</DeltaBadge>
);
};
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 (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-completion-rate-delta">
{formatSignedNumber(delta)}%
</DeltaBadge>
);
};
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 (
<span
className={cn(
'inline-flex items-center gap-x-0.5 rounded-full px-1.5 py-0.5 font-medium text-xs tabular-nums leading-none',
DELTA_TONE_CLASSES[tone],
)}
data-testid={testId}
>
{DeltaIcon && <DeltaIcon className="-ml-0.5 h-3 w-3" aria-hidden="true" />}
{children}
</span>
);
};
const DELTA_TONE_CLASSES: Record<DeltaTone, string> = {
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<DeltaTone, typeof ArrowUpRightIcon | null> = {
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';
};
@@ -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 (
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div>
<h2 className="font-semibold text-4xl">
<Trans>Analytics</Trans>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Usage overview for {name} · {rangeLabel}
</Trans>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{actions}
<AnalyticsRangePicker value={range} onValueChange={onRangeChange} />
</div>
</div>
);
};
@@ -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<unknown>;
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 (
<Alert variant="neutral" padding="tight" className={className} data-testid="analytics-error">
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
<span>
<Trans>This data could not be loaded.</Trans>
</span>
<Button variant="outline" size="sm" onClick={() => void handleRetry()} loading={isRetrying}>
<Trans>Retry</Trans>
</Button>
</AlertDescription>
</Alert>
);
};
@@ -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<CalendarProps['disabled'], undefined | unknown[]>;
/**
* 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<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [draft, setDraft] = useState<DraftRange | undefined>();
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 (
<Popover
open={isPickerOpen}
onOpenChange={(open) => {
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.
*/}
<Select value={value.range === 'custom' ? '' : value.range} onValueChange={handleSelectValueChange}>
<PopoverAnchor asChild>
<SelectTrigger
ref={triggerRef}
className="w-full sm:w-auto sm:min-w-44"
aria-label={_(msg`Date range`)}
data-testid="analytics-range"
>
<SelectValue placeholder={customLabel} />
</SelectTrigger>
</PopoverAnchor>
<SelectContent position="popper">
{ANALYTICS_PRESET_OPTIONS.map(({ value: optionValue, label }) => (
<SelectItem key={optionValue} value={optionValue}>
{_(label)}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={CUSTOM_RANGE_VALUE} data-testid="analytics-range-custom">
<Trans>Custom range…</Trans>
</SelectItem>
</SelectContent>
</Select>
<PopoverContent
ref={contentRef}
align="end"
className="w-auto p-0"
// The select refocuses its trigger (asynchronously) as it closes, which
// would otherwise dismiss the popover that has just opened and strand
// keyboard focus outside it. Pointer interaction with the trigger still
// dismisses the popover so the select can be reopened.
onFocusOutside={(event) => {
if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) {
return;
}
event.preventDefault();
const content = contentRef.current;
const firstTabbable = content?.querySelector<HTMLElement>(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();
}}
>
<div data-testid="analytics-range-calendar">
<Calendar
mode="range"
selected={draft}
onSelect={handleDaySelect}
numberOfMonths={numberOfMonths}
// Adjacent months would otherwise show the same days twice.
showOutsideDays={false}
defaultMonth={defaultMonth}
fromDate={earliestDay.toJSDate()}
toDate={today.toJSDate()}
disabled={disabledDays}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2">
<p className="text-muted-foreground text-sm" aria-live="polite">
{draftFrom && draftTo ? (
<>
{formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '}
<Plural value={draftDays} one="# day" other="# days" />
</>
) : draftFrom ? (
<Trans>Pick an end date</Trans>
) : (
<Trans>Pick a start date</Trans>
)}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={closePicker}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
size="sm"
onClick={handleApply}
disabled={!draftFrom || !draftTo}
data-testid="analytics-range-apply"
>
<Trans>Apply</Trans>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
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],
}));
@@ -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<unknown>;
testId: string;
};
export const AnalyticsStatCard = ({
icon: Icon,
title,
value,
description,
badge,
isLoading,
isError,
onRetry,
testId,
}: AnalyticsStatCardProps) => {
return (
<Card>
<CardContent className="flex flex-col p-5">
<div className="flex items-center justify-between gap-x-3">
<h3 className="font-medium text-muted-foreground text-sm">{title}</h3>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</div>
{isError ? (
<AnalyticsQueryError onRetry={onRetry} className="mt-3" />
) : isLoading ? (
<div className="mt-3 flex flex-col gap-y-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-3.5 w-32" />
</div>
) : (
<>
<div className="mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
<p className="font-semibold text-3xl text-foreground tabular-nums tracking-tight" data-testid={testId}>
{value}
</p>
{badge}
</div>
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
</>
)}
</CardContent>
</Card>
);
};
@@ -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<TGetTeamAnalyticsStatusBreakdownResponse>;
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 (
<Card className={cn('flex flex-col', className)} data-testid="analytics-status-breakdown">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Status breakdown</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created in this period</Trans>
</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
<CardContent className="flex flex-1 flex-col">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<div className="flex flex-col gap-y-4">
<Skeleton className="h-2.5 w-full rounded-full" />
<div className="flex flex-col gap-y-2">
{STATUS_ROWS.slice(0, 3).map((row) => (
<Skeleton key={row.key} className="h-5 w-full" />
))}
</div>
</div>
) : data.total === 0 ? (
<div className="flex flex-1 flex-col gap-y-4">
<StatusBar segments={[]} label={_(msg`No documents in this period`)} />
<p className="flex flex-1 items-center justify-center text-center text-muted-foreground text-sm">
<Trans>No documents in this period</Trans>
</p>
</div>
) : (
<div className="flex flex-col gap-y-2">
<StatusBar segments={rows} label={_(msg`Document status distribution`)} />
<ul className="flex flex-col divide-y divide-border">
{rows.map((row) => (
<li key={row.key} className="flex items-center justify-between gap-x-3 py-2.5 text-sm">
<div className="flex min-w-0 items-center gap-x-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
aria-hidden="true"
/>
<span className="truncate text-foreground">{_(row.label)}</span>
</div>
<div className="flex shrink-0 items-baseline gap-x-2 tabular-nums">
<span className="font-medium text-foreground" data-testid={`analytics-status-${row.key}`}>
{row.count.toLocaleString(i18n.locale)}
</span>
<span className="w-10 text-right text-muted-foreground">{row.percent}%</span>
</div>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
};
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 (
<div className="flex h-2.5 w-full gap-px overflow-hidden rounded-full bg-muted" role="img" aria-label={label}>
{segments.map((segment) => (
<div
key={segment.key}
className="h-full"
style={{ width: `${segment.percent}%`, backgroundColor: segment.color }}
/>
))}
</div>
);
};
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 = <T extends { count: number }>(rows: T[]): Array<T & { percent: number }> => {
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 }));
};
@@ -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<TTemplate extends AnalyticsTemplate> = {
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 = <TTemplate extends AnalyticsTemplate>({
query,
getTemplateHref,
renderTemplateMeta,
templatesHref,
className,
}: AnalyticsTemplateUsageCardProps<TTemplate>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
return (
<Card className={className} data-testid="analytics-template-usage">
<CardHeader>
<CardTitle>
<Trans>Template usage</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created from templates</Trans>
</CardDescription>
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 3 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-16" />
</li>
))}
</ul>
) : data.templates.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<FileTextIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">
<Trans>No documents were created from templates in this period</Trans>
</p>
{templatesHref && (
<Button variant="outline" size="sm" asChild>
<Link to={templatesHref}>
<Trans>View templates</Trans>
</Link>
</Button>
)}
</div>
) : (
<ol className="flex flex-col divide-y divide-border">
{data.templates.map((template, index) => {
const href = template.title === null ? null : getTemplateHref(template);
const meta = renderTemplateMeta?.(template);
return (
<li
key={template.id}
className="flex items-center gap-x-3 py-3 first:pt-0 last:pb-0"
data-testid="analytics-template-row"
>
<span className="w-5 shrink-0 text-muted-foreground text-xs tabular-nums" aria-hidden="true">
{index + 1}
</span>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
<FileTextIcon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
{template.title === null ? (
<span className="truncate text-muted-foreground text-sm">
<Trans>Unavailable template</Trans>
</span>
) : href !== null ? (
<Link to={href} className="truncate font-medium text-foreground text-sm hover:underline">
{template.title}
</Link>
) : (
<span className="truncate font-medium text-foreground text-sm">{template.title}</span>
)}
{(meta || template.updatedAt !== null) && (
<span className="truncate text-muted-foreground text-xs">
{meta}
{meta && template.updatedAt !== null && ' · '}
{template.updatedAt !== null && (
<Trans>Updated {formatRelativeDate(template.updatedAt, i18n.locale)}</Trans>
)}
</span>
)}
</div>
<span className="shrink-0 rounded-md border bg-muted px-2 py-0.5 font-medium text-foreground text-xs tabular-nums">
<Plural value={template.count} one="# use" other="# uses" />
</span>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
};
@@ -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 (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -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) => {
))}
</CommandGroup>
{hasSelection && (
{hasSelection && clearable && (
<>
<CommandSeparator />
<CommandGroup>
@@ -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 (
<div
className={cn(
@@ -29,7 +30,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
</div>
{children || (
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
<p className="mt-auto font-semibold text-4xl text-foreground leading-8" data-testid={testId}>
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
@@ -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 = () => {
</Link>
</DropdownMenuItem>
{analyticsPath && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={analyticsPath}>
<Trans>Analytics</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
to={
@@ -1,7 +1,11 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction, formatTeamUrl } from '@documenso/lib/utils/teams';
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
@@ -17,6 +21,7 @@ import {
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRight,
BarChart3Icon,
CalendarIcon,
MoreVerticalIcon,
PlusIcon,
@@ -114,11 +119,22 @@ export default function OrganisationSettingsTeamsPage() {
</p>
</div>
<Button asChild>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
<div className="flex items-center gap-2">
{canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && (
<Button variant="outline" asChild>
<Link to={formatOrganisationAnalyticsPath(organisation.url)}>
<BarChart3Icon className="mr-2 h-4 w-4" />
<Trans>Analytics</Trans>
</Link>
</Button>
)}
<Button asChild>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -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 <AnalyticsHydrateFallback />;
}
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 (
<div>
<AnalyticsPageHeader
avatarImageId={organisation.avatarImageId}
name={organisation.name}
range={range}
onRangeChange={setRange}
/>
{hasNoActivity && (
<AnalyticsNoActivityAlert range={range.range} onShowLastYear={() => setRange({ range: '12m' })} />
)}
<div className="mt-6 flex flex-col gap-4">
<AnalyticsOverviewCards
query={overviewQuery}
entity={{
icon: UsersIcon,
title: <Trans>Teams</Trans>,
testId: 'analytics-teams',
select: (data) => data.teams,
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<AnalyticsDocumentsOverTimeCard range={range} query={documentsOverTimeQuery} className="lg:col-span-2" />
<AnalyticsStatusBreakdownCard query={statusBreakdownQuery} />
</div>
<AnalyticsTemplateUsageCard
query={templateUsageQuery}
getTemplateHref={(template) =>
template.team !== null && template.envelopeId !== null
? `${formatTemplatesPath(template.team.url)}/${template.envelopeId}`
: null
}
renderTemplateMeta={(template) => template.team?.name}
/>
<AnalyticsActivityTableCard
query={teamActivityQuery}
rows={teamRows}
rangeKey={rangeKey}
title={<Trans>Team activity</Trans>}
description={<Trans>Documents sent by each team in this period</Trans>}
columnLabel={<Trans>Team</Trans>}
renderSummary={(count, activeCount) => (
<>
<Plural value={count} one="# team" other="# teams" /> · <Trans>{activeCount} active this period</Trans>
</>
)}
renderShowing={(visibleCount, totalCount) => (
<Trans>
Showing {visibleCount} of {totalCount} teams
</Trans>
)}
emptyLabel={<Trans>No teams</Trans>}
searchPlaceholder={_(msg`Search teams`)}
noSearchResultsLabel={<Trans>No teams match your search</Trans>}
testIdPrefix="team"
/>
</div>
</div>
);
}
const TEMPLATE_LIMIT = 5;
@@ -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 <AnalyticsHydrateFallback />;
}
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 (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<AnalyticsPageHeader
className="mt-8"
avatarImageId={team.avatarImageId}
name={team.name}
range={range}
onRangeChange={setRange}
actions={
canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && (
<Button variant="ghost" size="sm" className="text-muted-foreground" asChild>
<Link to={formatOrganisationAnalyticsPath(organisation.url)}>
<Trans>View organisation analytics</Trans>
<ArrowRightIcon className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
)
}
/>
{hasNoActivity && (
<AnalyticsNoActivityAlert range={range.range} onShowLastYear={() => setRange({ range: '12m' })} />
)}
<div className="mt-6 flex flex-col gap-4">
<AnalyticsOverviewCards
query={overviewQuery}
entity={{
icon: UsersIcon,
title: <Trans>Members</Trans>,
testId: 'analytics-members',
select: (data) => data.members,
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<AnalyticsDocumentsOverTimeCard range={range} query={documentsOverTimeQuery} className="lg:col-span-2" />
<AnalyticsStatusBreakdownCard query={statusBreakdownQuery} />
</div>
<AnalyticsTemplateUsageCard
query={templateUsageQuery}
templatesHref={templatesPath}
getTemplateHref={(template) =>
template.envelopeId !== null ? `${templatesPath}/${template.envelopeId}` : null
}
/>
<AnalyticsActivityTableCard
query={memberActivityQuery}
rows={memberRows}
rangeKey={rangeKey}
title={<Trans>Member activity</Trans>}
description={<Trans>Documents sent by each member in this period</Trans>}
columnLabel={<Trans>Member</Trans>}
renderSummary={(count, activeCount) => (
<>
<Plural value={count} one="# member" other="# members" /> ·{' '}
<Trans>{activeCount} active this period</Trans>
</>
)}
renderShowing={(visibleCount, totalCount) => (
<Trans>
Showing {visibleCount} of {totalCount} members
</Trans>
)}
emptyLabel={<Trans>No members</Trans>}
searchPlaceholder={_(msg`Search members`)}
noSearchResultsLabel={<Trans>No members match your search</Trans>}
testIdPrefix="member"
/>
</div>
</div>
);
}
const TEMPLATE_LIMIT = 5;
const formatMemberInitials = (name: string | null, email: string) => {
const initials = name ? extractInitials(name) : '';
return initials || email.slice(0, 1).toUpperCase();
};
+193
View File
@@ -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<TData> = {
data: TData | undefined;
isLoading: boolean;
isError: boolean;
refetch: () => Promise<unknown>;
};
export type TAnalyticsPresetRange = Exclude<TTeamAnalyticsRange, 'custom'>;
/**
* 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<TTeamAnalyticsRange, MessageDescriptor> = {
'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<TTeamAnalyticsRange, MessageDescriptor> = {
'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<string>({
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 };
};
+1 -1
View File
@@ -92,7 +92,7 @@ export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('INVALID_DOCUMENT_FILE', () => ({
title: msg`Error`,
description: msg`You cannot upload encrypted PDFs.`,
description: msg`The file is not a valid PDF or is password protected.`,
}))
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
title: msg`Error`,
+5 -5
View File
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.2",
"@libpdf/core": "^0.5.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -4480,9 +4480,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.5.1.tgz",
"integrity": "sha512-q+y4AEk9ngqyC1pdX/hNScnfh0ROr4vSiqX4C9CmuBzhtuVukbfTesXCaGvHZ3pksi8AJYDPGQ/0HzSeHZfi3A==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
@@ -17662,7 +17662,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
@@ -30844,6 +30843,7 @@
"clsx": "^1.2.1",
"cmdk": "^1.1.1",
"colord": "^2.9.3",
"date-fns": "^3.6.0",
"framer-motion": "^12.43.0",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
+1 -1
View File
@@ -106,7 +106,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.2",
"@libpdf/core": "^0.5.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -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<ReturnType<typeof seedScenario>>;
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<string, unknown>,
headers: Record<string, string> = {},
): Promise<TrpcResult> => {
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<TrpcResponseItem>(body) };
};
const trpcBatchQuery = async (
request: APIRequestContext,
calls: Array<{ procedure: string; input: Record<string, unknown> }>,
) => {
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<TrpcResponseItem[]>(body) ?? [] };
};
const parseJson = <T>(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();
};
@@ -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);
};
@@ -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<TGetTeamAnalyticsDocumentsOverTimeResponse, 'points'> } } } =
await response.json();
return body.result.data.json;
};
@@ -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<TGetOrganisationAnalyticsDocumentsOverTimeResponse> => {
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<string>`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,
};
};
@@ -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<TGetOrganisationAnalyticsOverviewResponse> => {
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),
},
};
};
@@ -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<ReturnType<typeof getOrganisationAnalyticsScope>>;
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<OrganisationAnalyticsTeam[]> => {
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' }));
};
@@ -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<TGetOrganisationAnalyticsStatusBreakdownResponse> => {
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, number> = {
[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],
};
};
@@ -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<TGetOrganisationAnalyticsTeamActivityResponse> => {
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,
};
};
@@ -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<TGetOrganisationAnalyticsTemplateUsageResponse> => {
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,
};
}),
};
};
@@ -9,14 +9,18 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
console.error(`PDF normalization error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is not a valid PDF',
message: 'The document is not a valid PDF or is password protected',
});
});
if (pdfDoc.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
if (!pdfDoc.isAuthenticated) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is password protected',
});
}
pdfDoc.removeProtection({ ignorePermissions: true });
}
pdfDoc.flattenLayers();
@@ -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<TGetTeamAnalyticsDocumentsOverTimeResponse> => {
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<string>`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,
};
};
@@ -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<TGetTeamAnalyticsMemberActivityResponse> => {
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,
};
};
@@ -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<TGetTeamAnalyticsOverviewResponse> => {
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),
},
};
};
@@ -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<DB, 'Envelope'>;
export type ApplyEnvelopeScope = (eb: EnvelopeScopeExpressionBuilder) => Expression<SqlBool>;
/**
* 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<ReturnType<typeof getTeamAnalyticsScope>>;
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<TeamAnalyticsMember[]> => {
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<number, TeamAnalyticsMember>();
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);
};
@@ -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<TGetTeamAnalyticsStatusBreakdownResponse> => {
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, number> = {
[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],
};
};
@@ -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<TGetTeamAnalyticsTemplateUsageResponse> => {
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,
};
}),
};
};
+13 -1
View File
@@ -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`.
@@ -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);
}
});
});
});
+174
View File
@@ -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<Exclude<TTeamAnalyticsRange, '12m' | 'custom'>, 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');
};
+4
View File
@@ -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.
*
+941
View File
@@ -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 = <T>(items: readonly T[]): T => items[Math.floor(rand() * items.length)];
const shuffle = <T>(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<string, number>;
documentsByVisibility: Record<string, number>;
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<Record<DocumentStatus, number>>): 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<SeededTeamSummary> => {
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<Record<string, number>>((acc, documentSpec) => {
acc[documentSpec.status] = (acc[documentSpec.status] ?? 0) + 1;
return acc;
}, {});
const documentsByVisibility = documentSpecs.reduce<Record<string, number>>((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();
}
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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<typeof ZOrganisationAnalyticsRequestSchema>;
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<typeof ZGetOrganisationAnalyticsOverviewRequestSchema>;
export type TGetOrganisationAnalyticsOverviewResponse = z.infer<typeof ZGetOrganisationAnalyticsOverviewResponseSchema>;
// -----------------------------------------------------------------------------
// 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
>;
@@ -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,
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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,
});
});
@@ -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<typeof ZTeamAnalyticsRangeSchema>;
/** 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<typeof ZAnalyticsRangeFieldsSchema>;
/** Base request shared by every team analytics procedure. */
export const ZTeamAnalyticsRequestSchema = ZAnalyticsRangeFieldsSchema.extend({
teamId: z.number().int().positive(),
});
export type TTeamAnalyticsRequest = z.infer<typeof ZTeamAnalyticsRequestSchema>;
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<typeof ZTeamAnalyticsResolvedRangeSchema>;
// -----------------------------------------------------------------------------
// 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<typeof ZGetTeamAnalyticsOverviewRequestSchema>;
export type TGetTeamAnalyticsOverviewResponse = z.infer<typeof ZGetTeamAnalyticsOverviewResponseSchema>;
// -----------------------------------------------------------------------------
// 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<typeof ZGetTeamAnalyticsDocumentsOverTimeRequestSchema>;
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<typeof ZGetTeamAnalyticsStatusBreakdownRequestSchema>;
export type TGetTeamAnalyticsStatusBreakdownResponse = z.infer<typeof ZGetTeamAnalyticsStatusBreakdownResponseSchema>;
// -----------------------------------------------------------------------------
// 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<typeof ZGetTeamAnalyticsTemplateUsageRequestSchema>;
export type TGetTeamAnalyticsTemplateUsageResponse = z.infer<typeof ZGetTeamAnalyticsTemplateUsageResponseSchema>;
// -----------------------------------------------------------------------------
// 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<typeof ZGetTeamAnalyticsMemberActivityRequestSchema>;
export type TGetTeamAnalyticsMemberActivityResponse = z.infer<typeof ZGetTeamAnalyticsMemberActivityResponseSchema>;
@@ -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,
+1
View File
@@ -35,6 +35,7 @@
"clsx": "^1.2.1",
"cmdk": "^1.1.1",
"colord": "^2.9.3",
"date-fns": "^3.6.0",
"framer-motion": "^12.43.0",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",