mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 08:14:31 +10:00
Merge branch 'main' into fix/branding-preferences-form-validation-messages
This commit is contained in:
@@ -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();
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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`,
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -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;
|
||||
};
|
||||
+81
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+32
@@ -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,
|
||||
});
|
||||
});
|
||||
+32
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user