mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 00:34:35 +10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e9c222d5c | ||
|
|
73d58bdf31 | ||
|
|
1de57a9e43 | ||
|
|
7f11c163fe | ||
|
|
5beb57dbf0 | ||
|
|
ead3104896 | ||
|
|
ae07df6061 |
@@ -0,0 +1,75 @@
|
||||
---
|
||||
date: 2026-05-29
|
||||
title: Team Analytics Dashboard
|
||||
---
|
||||
|
||||
> Source: issue #242 (documenso/backlog-internal, "Team signing analytics/dashboard"). Redo of stale PR #1976 (`feat/team-dashboard`, closed DIRTY) — build fresh on `main`; do NOT resurrect that branch or its parallel `analytics/` module. Scope locked via interview 2026-05-29.
|
||||
|
||||
## V1 decisions (locked)
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Goal | Team **document usage** dashboard. NOT signature / recipient / user metrics; no "cumulative active users". |
|
||||
| Headline | **Documents Sent** (non-draft) + **Completed**. Raw counts only — no completion-rate or derived metrics, no period-over-period deltas. |
|
||||
| State tiles | Draft · Pending · Completed · **Declined (= `REJECTED`)**. "Voided" dropped (no `VOIDED` status exists). Render as a **row of compact stat tiles** (big number + small label). |
|
||||
| Attribution | By document **owner** (`Envelope.userId`, reuse `senderIds`). Full per-member, **no** privacy guardrail. |
|
||||
| Member control | **Filter only** — multiselect of team members + easy "All"; every number reflects the selected subset. Reuse `documents-table-sender-filter`. |
|
||||
| Time filter | **Calendar presets** (This week / This month / This quarter / This year, Last month, …). Default **This month / last 30 days**. **No per-bucket breakdown, no trend chart** — one total per metric for the range. Boundaries in the **viewer's local timezone**. |
|
||||
| Folder scope | Aggregate **all folders**. Folder filter control → V2. |
|
||||
| Counts | **Exact** — drop `STATS_COUNT_CAP` on the analytics path. |
|
||||
| Freshness | **Live** on every load (no cache in V1). |
|
||||
| Number format | Full, thousands-separated (`1,234`). |
|
||||
| Inbox | **Excluded** — dashboard = docs the team PRODUCES, not receives. |
|
||||
| Placement | Standalone top-level route `/t/:teamUrl/analytics` + **primary nav entry**. |
|
||||
| Access | `ADMIN` + `MANAGER` only. `MEMBER` → **hide nav + silent redirect** to documents (no 403, no existence leak). |
|
||||
| Rollout | Behind a **feature flag**, then open to all teams. No plan/tier gating. |
|
||||
| Empty state | Friendly empty state with CTA to send the first document. |
|
||||
| Export | Out of V1 (gold-plating). |
|
||||
| Deferred → V2 | Folder filter, personal/individual analytics, org-wide cross-team rollup, trend charts, period deltas, derived metrics. |
|
||||
|
||||
## Metric semantics — READ THIS
|
||||
|
||||
**Event model, bucketed by status date.** Each number counts documents that ENTERED that state during the selected period, each on its OWN date axis:
|
||||
|
||||
- **Documents Sent** = non-draft, `createdAt` ∈ period. *(createdAt is the sent-date proxy — see Risks.)*
|
||||
- **Pending** = currently `PENDING`, `createdAt` ∈ period (sent in period, still pending).
|
||||
- **Completed** = status `COMPLETED`, **`Envelope.completedAt`** ∈ period.
|
||||
- **Declined** = status `REJECTED`, rejection time ∈ period — sourced from **`DocumentAuditLog` `DOCUMENT_RECIPIENT_REJECTED`** (there is no `Envelope.rejectedAt`).
|
||||
- **Draft** = currently `DRAFT`, `createdAt` ∈ period (informational; excluded from "Sent").
|
||||
|
||||
⚠️ **Tiles are independent activity counts on different date axes → they do NOT sum to "Documents Sent."** A doc sent in April but completed in May lands in May's Completed, not April's. The UI must NOT present the tiles as if they add up to the headline (no "x of y" framing, no stacked-total visuals).
|
||||
|
||||
## Backend
|
||||
|
||||
- **New dedicated analytics query** (e.g. `packages/lib/server-only/team/get-team-analytics.ts`). **Do NOT call `getStats` directly** — its semantics diverge (root-folder-only, `STATS_COUNT_CAP`-capped, every status bucketed by `createdAt`). **Reuse its *patterns*:** Kysely builder, team `visibilityFilter` / `teamDeletedFilter`, `senderIds`, `EnvelopeType.DOCUMENT`, `deletedAt IS NULL`.
|
||||
- Per-metric windows: `createdAt` for Sent/Pending/Draft; `Envelope.completedAt` for Completed; join `DocumentAuditLog` (`type = DOCUMENT_RECIPIENT_REJECTED`, by `envelopeId`, earliest timestamp) for Declined. Exact `COUNT(*)` — no cap.
|
||||
- Period: resolve `[start, end)` from preset + **viewer timezone** (client sends IANA zone/offset; default UTC if absent). Use Luxon (already imported in `get-stats.ts`).
|
||||
- tRPC: new `team.getAnalytics` (team-router per-file pattern, `packages/trpc/server/team-router/`). Input `{ teamId, period | { from, to }, senderIds[] }`. **Gate `ADMIN`/`MANAGER`** via team role (`getTeamById` → `currentTeamRole`); deny `MEMBER`.
|
||||
|
||||
## Frontend
|
||||
|
||||
- Route `apps/remix/app/routes/_authenticated+/t.$teamUrl+/analytics._index.tsx`. Loader resolves team + role; `MEMBER` → `redirect` to `/t/:teamUrl/documents`. Behind feature flag (hidden + redirect when off).
|
||||
- Components (tight, glanceable): headline (Documents Sent, Completed) + compact tile row (Draft/Pending/Completed/Declined); member multiselect (reuse `documents-table-sender-filter`, "All"); calendar-preset period selector; empty state.
|
||||
- Nav entry in `apps/remix/app/components/general/menu-switcher.tsx`, gated by role + flag.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Match existing Documenso UI** — Shadcn + Tailwind cards/typography from documents index & admin stats; reuse primitives, don't invent.
|
||||
2. **Consult the `uidotsh` (`/ui`) skill for EVERY design decision.** No layout/spacing/component/visual choice ships without first fetching `uidotsh://ui`, routing to the matching subskill, and loading its design-guideline files before writing markup. Subskills: `ideas` (tile-row layout options), `design` (`design-guidelines.md`), `finalize` = `componentize` + `canonicalize-tailwind`, `add-dark-mode`, `make-responsive`.
|
||||
|
||||
## Approach order
|
||||
|
||||
Per ElTimuro: **iterate on the UI first, then finalize the backend.** Stand up the page + filters against a thin query, agree the tile layout via `uidotsh`, then lock the analytics query (date axes, audit-log join, exact counts).
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit (`get-team-analytics`): doc sent-April/completed-May counts in May's Completed only (not April); Declined dated from audit log; all-folders aggregation; exact counts beyond the old cap; `senderIds` attribution; timezone-correct period boundaries.
|
||||
- Unit (route/router): `ADMIN`/`MANAGER` allowed, `MEMBER` denied/redirected; flag off → nav hidden + redirect.
|
||||
- E2E (Playwright, `@documenso/app-tests`): admin sees dashboard; member redirected away; member-filter and period-preset changes move the numbers; new team shows empty state.
|
||||
|
||||
## Risks / open
|
||||
|
||||
1. **Sent-date proxy:** `createdAt` ≠ true send time for draft-then-sent docs. More accurate = `DocumentAuditLog DOCUMENT_SENT`. V1 uses `createdAt` (no extra join); revisit if attribution looks wrong.
|
||||
2. **Audit-log join cost** for Declined on large teams (live + uncapped). Acceptable for V1; caching/precompute is the V2 lever if it bites.
|
||||
3. **Non-summing tiles** can confuse stakeholders — needs clear labels/tooltip; confirm framing with ElTimuro on the UI pass.
|
||||
4. **Timezone plumbing:** client must send its zone and the server must bucket in it. Confirm no existing team-timezone setting should take precedence.
|
||||
@@ -168,6 +168,8 @@ NEXT_PRIVATE_REDIS_PREFIX="documenso"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
# OPTIONAL: Leave blank to disable billing.
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Team analytics dashboard kill-switch. Enabled by default; set to "false" to hide it during rollout.
|
||||
NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED=
|
||||
# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal).
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to disable email/password signup only.
|
||||
|
||||
@@ -290,10 +290,11 @@ export const EnvelopesBulkDownloadDialog = ({
|
||||
{isOverDownloadLimit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
|
||||
continue.
|
||||
</Trans>
|
||||
<Plural
|
||||
value={MAX_BULK_DOWNLOAD_ENVELOPES}
|
||||
one="You can download up to # document at a time. Deselect some documents to continue."
|
||||
other="You can download up to # documents at a time. Deselect some documents to continue."
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
const DEFAULT_PERIOD = 'month';
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: 'week', label: msg`This week` },
|
||||
{ value: 'month', label: msg`This month` },
|
||||
{ value: 'quarter', label: msg`This quarter` },
|
||||
{ value: 'year', label: msg`This year` },
|
||||
{ value: 'lastMonth', label: msg`Last month` },
|
||||
{ value: 'last7Days', label: msg`Last 7 days` },
|
||||
{ value: 'last30Days', label: msg`Last 30 days` },
|
||||
] as const;
|
||||
|
||||
export const AnalyticsPeriodSelector = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const parsed = ZAnalyticsPeriodSchema.safeParse(searchParams?.get('period') ?? DEFAULT_PERIOD);
|
||||
|
||||
return parsed.success ? parsed.data : DEFAULT_PERIOD;
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === DEFAULT_PERIOD) {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{_(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { defaultFilter as commandScore } from 'cmdk';
|
||||
import {
|
||||
@@ -596,9 +596,21 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
|
||||
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{hasValidSearch ? (
|
||||
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
|
||||
isVisibleCountCapped ? (
|
||||
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
|
||||
) : (
|
||||
<Plural
|
||||
value={totalVisibleCount}
|
||||
one="# result"
|
||||
other="# results"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Trans>{totalVisibleCount} items</Trans>
|
||||
<Plural
|
||||
value={totalVisibleCount}
|
||||
one="# item"
|
||||
other="# items"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -44,7 +46,7 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
const links = [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
label: msg`Documents`,
|
||||
@@ -54,6 +56,19 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
|
||||
label: msg`Templates`,
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
currentTeam &&
|
||||
IS_TEAM_ANALYTICS_ENABLED() &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
|
||||
) {
|
||||
links.push({
|
||||
href: `/t/${currentTeam.url}/analytics`,
|
||||
label: msg`Analytics`,
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteTeamAction } 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';
|
||||
@@ -56,7 +58,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
const links = [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
text: t`Documents`,
|
||||
@@ -65,6 +67,20 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
href: `/t/${teamUrl}/templates`,
|
||||
text: t`Templates`,
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
currentTeam &&
|
||||
IS_TEAM_ANALYTICS_ENABLED() &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
|
||||
) {
|
||||
links.push({
|
||||
href: `/t/${currentTeam.url}/analytics`,
|
||||
text: t`Analytics`,
|
||||
});
|
||||
}
|
||||
|
||||
links.push(
|
||||
{
|
||||
href: '/inbox',
|
||||
text: t`Inbox`,
|
||||
@@ -73,7 +89,9 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
href: '/settings/profile',
|
||||
text: t`Settings`,
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
return links;
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,13 +39,14 @@ const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
|
||||
|
||||
/**
|
||||
* Admin list pages which support prefilling their search from the URL, used
|
||||
* for the "View all results" links on capped groups. Teams, recipients and
|
||||
* for the "View all results" links on capped groups. Teams and
|
||||
* subscriptions have no admin list pages.
|
||||
*/
|
||||
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
||||
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
||||
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
||||
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
||||
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
|
||||
};
|
||||
|
||||
export type UseAdminSearchCategoriesOptions = {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
@@ -30,6 +30,12 @@ export default function AdminDocumentsPage() {
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
|
||||
const urlTerm = searchParams?.get?.('term') ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
setTerm(urlTerm);
|
||||
}, [urlTerm]);
|
||||
|
||||
const { data: findDocumentsData, isPending: isFindDocumentsLoading } = trpc.admin.document.find.useQuery(
|
||||
{
|
||||
query: debouncedTerm,
|
||||
@@ -131,7 +137,7 @@ export default function AdminDocumentsPage() {
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search by document title, team:123 or user:123`)}
|
||||
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteTeamAction, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { parseAsStringLiteral, useQueryStates } from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { AnalyticsPeriodSelector } from '~/components/general/analytics-period-selector';
|
||||
import { CardMetric } from '~/components/general/metric-card';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
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) {
|
||||
// Behind a rollout flag: silently send everyone back to documents when off.
|
||||
if (!IS_TEAM_ANALYTICS_ENABLED()) {
|
||||
throw redirect(formatDocumentsPath(params.teamUrl));
|
||||
}
|
||||
|
||||
const session = await getSession(request);
|
||||
|
||||
const team = await getTeamByUrl({
|
||||
userId: session.user.id,
|
||||
teamUrl: params.teamUrl,
|
||||
});
|
||||
|
||||
// Admins and managers only. Members are silently redirected (no existence leak).
|
||||
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
throw redirect(formatDocumentsPath(params.teamUrl));
|
||||
}
|
||||
}
|
||||
|
||||
export default function TeamAnalyticsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [{ period, senderIds }] = useQueryStates({
|
||||
period: parseAsStringLiteral(ZAnalyticsPeriodSchema.options),
|
||||
senderIds: documentsSearchParams.senderIds,
|
||||
});
|
||||
|
||||
const timezone = useMemo(() => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { data, isLoading } = trpc.team.getAnalytics.useQuery({
|
||||
teamId: team.id,
|
||||
period: period ?? undefined,
|
||||
timezone,
|
||||
senderIds: senderIds ?? [],
|
||||
});
|
||||
|
||||
const analytics = data ?? {
|
||||
sent: 0,
|
||||
draft: 0,
|
||||
pending: 0,
|
||||
completed: 0,
|
||||
declined: 0,
|
||||
};
|
||||
|
||||
const hasActivity =
|
||||
analytics.sent > 0 ||
|
||||
analytics.draft > 0 ||
|
||||
analytics.pending > 0 ||
|
||||
analytics.completed > 0 ||
|
||||
analytics.declined > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Analytics</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap items-center gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<DocumentsTableSenderFilter teamId={team.id} />
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<AnalyticsPeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{isLoading ? (
|
||||
<SpinnerBox className="py-32" />
|
||||
) : hasActivity ? (
|
||||
<div data-testid="team-analytics-content">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div data-testid="metric-sent" className="contents">
|
||||
<CardMetric title={_(msg`Documents Sent`)} value={analytics.sent} />
|
||||
</div>
|
||||
<div data-testid="metric-completed-headline" className="contents">
|
||||
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<CardMetric title={_(msg`Draft`)} value={analytics.draft} />
|
||||
<CardMetric title={_(msg`Pending`)} value={analytics.pending} />
|
||||
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
|
||||
<CardMetric title={_(msg`Declined`)} value={analytics.declined} />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 max-w-3xl text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Each tile counts documents that entered that state during the selected period, on its own date. They are
|
||||
independent activity counts and do not add up to Documents Sent.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="team-analytics-empty"
|
||||
className="flex flex-col items-center justify-center rounded-lg border border-border border-dashed py-20 text-center"
|
||||
>
|
||||
<h3 className="font-semibold text-foreground text-lg">
|
||||
<Trans>No analytics to show yet</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 max-w-md text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
There's no document activity for the selected period. Send your first document to start tracking your
|
||||
team's usage here.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild className="mt-6">
|
||||
<Link to={formatDocumentsPath(team.url)}>
|
||||
<Trans>Send a document</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -437,3 +437,98 @@ test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without e
|
||||
|
||||
expect(adminSearchRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: capped recipients group links to the admin documents page', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientPrefix = `viewall-recipient-${nanoid()}`;
|
||||
|
||||
// Seed 5 documents, each with one recipient email sharing the prefix, to
|
||||
// hit the 5 result cap on the recipients group.
|
||||
const documents = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
documents.push(
|
||||
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-viewall-${nanoid()}` },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
|
||||
|
||||
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
|
||||
|
||||
// Only the recipients group matches the prefix, so this is its link.
|
||||
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
|
||||
|
||||
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
|
||||
'href',
|
||||
`/admin/documents?term=${encodeURIComponent(`recipient:${recipientPrefix}`)}`,
|
||||
);
|
||||
|
||||
await viewAllOption.click();
|
||||
|
||||
await page.waitForURL((url) => url.pathname === '/admin/documents');
|
||||
|
||||
// The term input is prefilled with the recipient query and the matching
|
||||
// documents are listed.
|
||||
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
|
||||
|
||||
for (const document of documents) {
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: view all results updates the documents page when already on it', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientPrefix = `viewall-live-${nanoid()}`;
|
||||
|
||||
// Seed 5 documents with recipients matching the prefix to hit the cap, and
|
||||
// one control document whose recipient does not match: with a stale
|
||||
// (unfiltered) query the control would show, with the filter it must not.
|
||||
const documents = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
documents.push(
|
||||
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-live-${nanoid()}` },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const controlDocument = await seedPendingDocument(sender, team.id, [`control-${nanoid()}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-live-control-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Start ON the admin documents page: the buggy state initializer has
|
||||
// already run with an empty term.
|
||||
await page.goto('/admin/documents');
|
||||
|
||||
await expect(page.getByPlaceholder(/Search by document title/)).toBeVisible();
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
|
||||
|
||||
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('option').filter({ hasText: 'View all results' }).first().click();
|
||||
|
||||
await page.waitForURL((url) => url.searchParams.get('term') === `recipient:${recipientPrefix}`);
|
||||
|
||||
// The same-route navigation must update both the input and the results.
|
||||
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
|
||||
|
||||
await expect(page.getByRole('link', { name: documents[0].title })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: controlDocument.title })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
type AdminFindDocumentsResult = {
|
||||
data: Array<{ envelopeId: string; title: string }>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const callAdminFindDocuments = async (page: Page, query: string) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: { query, page: 1, perPage: 20 } }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.document.find?input=${inputParam}`;
|
||||
|
||||
const res = await page.context().request.get(url);
|
||||
|
||||
return {
|
||||
res,
|
||||
result: res.ok()
|
||||
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
((await res.json()).result.data.json as AdminFindDocumentsResult)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: non-admin user is rejected with 401', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email });
|
||||
|
||||
const { res } = await callAdminFindDocuments(page, 'recipient:anything');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
// ─── recipient: prefix ───────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient email', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientEmail = `recipient-find-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientEmail], {
|
||||
createDocumentOptions: { title: `recipient-find-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
const otherDocument = await seedPendingDocument(sender, team.id, [`other-${nanoid()}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-find-other-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientEmail}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
expect(result?.data.map((document) => document.envelopeId)).not.toContain(otherDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient name case-insensitively', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientName = `recipient-name-${nanoid()}`;
|
||||
const { user: recipientUser } = await seedUser({ name: recipientName });
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientUser], {
|
||||
createDocumentOptions: { title: `recipient-name-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Query the uppercased name: matching must be case-insensitive.
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientName.toUpperCase()}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with empty value returns no results', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const emptyValueSearch = await callAdminFindDocuments(page, 'recipient:');
|
||||
|
||||
expect(emptyValueSearch.res.ok()).toBeTruthy();
|
||||
expect(emptyValueSearch.result?.data).toEqual([]);
|
||||
expect(emptyValueSearch.result?.count).toBe(0);
|
||||
|
||||
// Whitespace-only values are treated the same as empty.
|
||||
const whitespaceValueSearch = await callAdminFindDocuments(page, 'recipient: ');
|
||||
|
||||
expect(whitespaceValueSearch.res.ok()).toBeTruthy();
|
||||
expect(whitespaceValueSearch.result?.data).toEqual([]);
|
||||
expect(whitespaceValueSearch.result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with no matches returns no results', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, 'recipient:zzzz-no-such-recipient-9x7q');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data).toEqual([]);
|
||||
expect(result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with numeric value matches by exact recipient ID', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [`recipient-id-${nanoid()}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-id-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
const recipient = matchingDocument.recipients[0];
|
||||
|
||||
// A decoy whose recipient email contains the ID as text: an exact ID lookup
|
||||
// must exclude it, while an accidental "ID or contains" match would not.
|
||||
const decoyDocument = await seedPendingDocument(
|
||||
sender,
|
||||
team.id,
|
||||
[`decoy-${recipient.id}-${nanoid()}@test.documenso.com`],
|
||||
{
|
||||
createDocumentOptions: { title: `recipient-id-decoy-${nanoid()}` },
|
||||
},
|
||||
);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipient.id}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
expect(result?.data.map((document) => document.envelopeId)).not.toContain(decoyDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with nonexistent recipient ID returns no results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Int4 max - 1: a valid ID-shaped number that no autoincrement recipient
|
||||
// sequence will plausibly reach, and that no other test seeds as text.
|
||||
const { res, result } = await callAdminFindDocuments(page, 'recipient:2147483646');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data).toEqual([]);
|
||||
expect(result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with oversized number falls back to text search', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
|
||||
// treated as text (and must not 500).
|
||||
const oversizedNumber = '99999999999999';
|
||||
|
||||
const matchingDocument = await seedPendingDocument(
|
||||
sender,
|
||||
team.id,
|
||||
[`${oversizedNumber}-${nanoid()}@test.documenso.com`],
|
||||
{
|
||||
createDocumentOptions: { title: `recipient-oversized-${nanoid()}` },
|
||||
},
|
||||
);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${oversizedNumber}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: user and team prefixes with oversized numbers return no results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// 99999999999999 exceeds Int4, so it can never be a valid user or team ID.
|
||||
// The ID schema must reject it so the query returns empty instead of
|
||||
// overflowing Postgres and erroring.
|
||||
const userSearch = await callAdminFindDocuments(page, 'user:99999999999999');
|
||||
|
||||
expect(userSearch.res.ok()).toBeTruthy();
|
||||
expect(userSearch.result?.data).toEqual([]);
|
||||
expect(userSearch.result?.count).toBe(0);
|
||||
|
||||
const teamSearch = await callAdminFindDocuments(page, 'team:99999999999999');
|
||||
|
||||
expect(teamSearch.res.ok()).toBeTruthy();
|
||||
expect(teamSearch.result?.data).toEqual([]);
|
||||
expect(teamSearch.result?.count).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getTeamAnalytics } from '@documenso/lib/server-only/team/get-team-analytics';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedCompletedDocument,
|
||||
seedDraftDocument,
|
||||
seedPendingDocument,
|
||||
seedTeamDocuments,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// Fixed, "now"-independent windows so the date-axis assertions are deterministic.
|
||||
const SENT_IN_APRIL = new Date('2026-04-10T12:00:00.000Z');
|
||||
const ACTIONED_IN_MAY = new Date('2026-05-10T12:00:00.000Z');
|
||||
|
||||
const APRIL = {
|
||||
periodStart: new Date('2026-04-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-05-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const MAY = {
|
||||
periodStart: new Date('2026-05-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
// ─── Query semantics (no browser / dev server) ───────────────────────────────
|
||||
|
||||
test('[ANALYTICS]: a completed document is counted by completedAt, not createdAt', async () => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// Sent in April, completed in May — the document lands on two different axes.
|
||||
await seedCompletedDocument(owner, team.id, [], {
|
||||
createDocumentOptions: {
|
||||
createdAt: SENT_IN_APRIL,
|
||||
completedAt: ACTIONED_IN_MAY,
|
||||
},
|
||||
});
|
||||
|
||||
const april = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...APRIL });
|
||||
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
|
||||
|
||||
// Created (and non-draft) in April → counts as Sent in April only.
|
||||
expect(april.sent).toBe(1);
|
||||
expect(april.completed).toBe(0);
|
||||
|
||||
// Completed in May → counts as Completed in May only, never as Sent in May.
|
||||
expect(may.sent).toBe(0);
|
||||
expect(may.completed).toBe(1);
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: declined documents are dated from the rejection audit log', async () => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// Document was created in April but only rejected in May.
|
||||
const rejected = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: {
|
||||
status: DocumentStatus.REJECTED,
|
||||
createdAt: SENT_IN_APRIL,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: {
|
||||
envelopeId: rejected.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
createdAt: ACTIONED_IN_MAY,
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
const april = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...APRIL });
|
||||
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
|
||||
|
||||
// Rejection happened in May, so April (the creation month) records no decline.
|
||||
expect(april.declined).toBe(0);
|
||||
expect(may.declined).toBe(1);
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: counts attribute by owner and aggregate across all folders', async () => {
|
||||
const { team, owner, organisation } = await seedTeam({ createTeamMembers: 1 });
|
||||
|
||||
const member = organisation.members[1].user;
|
||||
|
||||
const folder = await seedBlankFolder(owner, team.id);
|
||||
|
||||
// Owner: one pending in the root folder, one pending nested in a folder.
|
||||
await seedPendingDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
await seedPendingDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY, folderId: folder.id },
|
||||
});
|
||||
|
||||
// Member: one pending in the root folder.
|
||||
await seedPendingDocument(member, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
|
||||
// All folders are aggregated: the nested document is included.
|
||||
const everyone = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
|
||||
expect(everyone.pending).toBe(3);
|
||||
|
||||
// Attribution by owner via senderIds.
|
||||
const ownerOnly = await getTeamAnalytics({
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
senderIds: [owner.id],
|
||||
...MAY,
|
||||
});
|
||||
expect(ownerOnly.pending).toBe(2);
|
||||
|
||||
const memberOnly = await getTeamAnalytics({
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
senderIds: [member.id],
|
||||
...MAY,
|
||||
});
|
||||
expect(memberOnly.pending).toBe(1);
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: "Documents Sent" excludes drafts but counts every other status', async () => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await seedDraftDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
await seedPendingDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
await seedCompletedDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY, completedAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
|
||||
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
|
||||
|
||||
expect(may.draft).toBe(1);
|
||||
expect(may.pending).toBe(1);
|
||||
expect(may.completed).toBe(1);
|
||||
// Sent = non-draft created in the period (pending + completed), drafts excluded.
|
||||
expect(may.sent).toBe(2);
|
||||
});
|
||||
|
||||
// ─── Access control + dashboard UI (requires the running dev server) ──────────
|
||||
|
||||
test('[ANALYTICS]: a team admin sees the dashboard and filters move the numbers', async ({ page }) => {
|
||||
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamOwner.email,
|
||||
redirectPath: `/t/${team.url}/analytics`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
|
||||
await expect(page.getByTestId('team-analytics-content')).toBeVisible();
|
||||
|
||||
// teamMember1 (1 completed) + teamMember2 (2 pending) = 3 non-draft documents sent.
|
||||
await expect(page.getByTestId('metric-sent')).toContainText('3');
|
||||
|
||||
// Filtering to teamMember2 narrows the sent count to their 2 pending documents.
|
||||
await page.getByRole('button', { name: 'Sender' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
await expect(page.getByTestId('metric-sent')).toContainText('2');
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: a team member is redirected away from the dashboard', async ({ page }) => {
|
||||
const { team } = await seedTeam();
|
||||
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: member.email,
|
||||
redirectPath: `/t/${team.url}/analytics`,
|
||||
});
|
||||
|
||||
// The loader silently redirects members back to documents (no 403, no leak).
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
|
||||
|
||||
expect(page.url()).toContain(`/t/${team.url}/documents`);
|
||||
expect(page.url()).not.toContain('/analytics');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: a team with no document activity shows the empty state', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/analytics`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('team-analytics-empty')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Send a document' })).toBeVisible();
|
||||
await expect(page.getByTestId('team-analytics-content')).toHaveCount(0);
|
||||
});
|
||||
@@ -53,6 +53,15 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
|
||||
|
||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||
|
||||
/**
|
||||
* Team analytics dashboard rollout flag.
|
||||
*
|
||||
* Acts as a kill-switch: enabled by default and disabled only when the env var
|
||||
* is explicitly set to "false". This keeps the feature available to all teams
|
||||
* while leaving a single lever to gate it off during rollout.
|
||||
*/
|
||||
export const IS_TEAM_ANALYTICS_ENABLED = () => env('NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED') !== 'false';
|
||||
|
||||
/**
|
||||
* Whether this instance is Documenso Cloud (managed SaaS).
|
||||
*
|
||||
|
||||
@@ -93,7 +93,7 @@ export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDe
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Waiting for others to complete signing.`),
|
||||
subject: i18n._(msg`Waiting for others to complete signing`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,13 @@ export interface AdminFindDocumentsOptions {
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
const ZPositiveIntegerSchema = z.coerce.number().int().positive();
|
||||
const MAX_POSTGRES_INT = 2147483647;
|
||||
|
||||
/**
|
||||
* IDs are Postgres int4 columns: values above the range can never be valid
|
||||
* IDs and would make Prisma throw on overflow, so the schema rejects them.
|
||||
*/
|
||||
const ZPositiveIntegerSchema = z.coerce.number().int().positive().max(MAX_POSTGRES_INT);
|
||||
|
||||
const emptyResponse = {
|
||||
data: [],
|
||||
@@ -58,7 +64,41 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
}
|
||||
}
|
||||
|
||||
if (query && query?.startsWith('envelope_')) {
|
||||
if (query?.startsWith('recipient:')) {
|
||||
const recipientQuery = query.slice('recipient:'.length).trim();
|
||||
|
||||
if (recipientQuery.length === 0) {
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
// Bare numeric values are exact recipient ID lookups, consistent with the
|
||||
// user: and team: prefixes. Oversized numbers cannot be IDs and fall back
|
||||
// to the text search, mirroring the admin global search.
|
||||
const parsedRecipientId = ZPositiveIntegerSchema.safeParse(recipientQuery);
|
||||
|
||||
if (/^\d+$/.test(recipientQuery) && parsedRecipientId.success) {
|
||||
termFilters = {
|
||||
recipients: {
|
||||
some: {
|
||||
id: parsedRecipientId.data,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
termFilters = {
|
||||
recipients: {
|
||||
some: {
|
||||
OR: [
|
||||
{ email: { contains: recipientQuery, mode: 'insensitive' } },
|
||||
{ name: { contains: recipientQuery, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (query?.startsWith('envelope_')) {
|
||||
termFilters = {
|
||||
id: {
|
||||
equals: query,
|
||||
@@ -66,7 +106,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
};
|
||||
}
|
||||
|
||||
if (query && query?.startsWith('document_')) {
|
||||
if (query?.startsWith('document_')) {
|
||||
termFilters = {
|
||||
secondaryId: {
|
||||
equals: query,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveAnalyticsPeriod } from './analytics-period';
|
||||
|
||||
const iso = (date: Date) => date.toISOString();
|
||||
|
||||
describe('resolveAnalyticsPeriod', () => {
|
||||
// Friday, 2026-05-15. May 2026 is EDT (UTC-4) in the US.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resolves "month" to the current calendar month, half-open, in UTC by default', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'month' });
|
||||
|
||||
expect(iso(start)).toBe('2026-05-01T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-06-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('anchors month boundaries to the viewer timezone', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'America/New_York' });
|
||||
|
||||
// Local midnight in EDT is 04:00 UTC.
|
||||
expect(iso(start)).toBe('2026-05-01T04:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-06-01T04:00:00.000Z');
|
||||
});
|
||||
|
||||
it('shifts the window for a zone ahead of UTC', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'Asia/Tokyo' });
|
||||
|
||||
// JST is UTC+9, so local midnight is the previous day at 15:00 UTC.
|
||||
expect(iso(start)).toBe('2026-04-30T15:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-05-31T15:00:00.000Z');
|
||||
});
|
||||
|
||||
it('falls back to UTC for an invalid timezone', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'Not/AZone' });
|
||||
|
||||
expect(iso(start)).toBe('2026-05-01T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-06-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('resolves "lastMonth" contiguous with the start of the current month', () => {
|
||||
const lastMonth = resolveAnalyticsPeriod({ period: 'lastMonth' });
|
||||
const thisMonth = resolveAnalyticsPeriod({ period: 'month' });
|
||||
|
||||
expect(iso(lastMonth.start)).toBe('2026-04-01T00:00:00.000Z');
|
||||
expect(iso(lastMonth.end)).toBe('2026-05-01T00:00:00.000Z');
|
||||
expect(iso(lastMonth.end)).toBe(iso(thisMonth.start));
|
||||
});
|
||||
|
||||
it('resolves "quarter" to the current calendar quarter', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'quarter' });
|
||||
|
||||
expect(iso(start)).toBe('2026-04-01T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-07-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('resolves "year" to the current calendar year', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'year' });
|
||||
|
||||
expect(iso(start)).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2027-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('resolves "last30Days" as a trailing 30-day window including today', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'last30Days' });
|
||||
|
||||
expect(iso(start)).toBe('2026-04-16T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-05-16T00:00:00.000Z');
|
||||
expect(end.getTime() - start.getTime()).toBe(30 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('resolves "last7Days" as a trailing 7-day window including today', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'last7Days' });
|
||||
|
||||
expect(iso(start)).toBe('2026-05-09T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-05-16T00:00:00.000Z');
|
||||
expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('resolves "week" to a 7-day ISO week starting Monday', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'week' });
|
||||
|
||||
// 2026-05-15 is a Friday; the ISO week starts Monday 2026-05-11.
|
||||
expect(iso(start)).toBe('2026-05-11T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-05-18T00:00:00.000Z');
|
||||
expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Calendar period presets for the team analytics dashboard. Each resolves to a
|
||||
* half-open `[start, end)` instant range in the viewer's timezone.
|
||||
*
|
||||
* Pure (zod + luxon, no Prisma) so it can be unit-tested without a database and
|
||||
* shared with the request schema without pulling server-only code anywhere it
|
||||
* should not go.
|
||||
*/
|
||||
export const ZAnalyticsPeriodSchema = z.enum([
|
||||
'week',
|
||||
'month',
|
||||
'quarter',
|
||||
'year',
|
||||
'lastMonth',
|
||||
'last7Days',
|
||||
'last30Days',
|
||||
]);
|
||||
|
||||
export type AnalyticsPeriod = z.infer<typeof ZAnalyticsPeriodSchema>;
|
||||
|
||||
export const DEFAULT_ANALYTICS_PERIOD: AnalyticsPeriod = 'month';
|
||||
|
||||
export type AnalyticsPeriodRange = {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a calendar preset into a half-open `[start, end)` instant range,
|
||||
* anchored to the viewer's timezone.
|
||||
*
|
||||
* Falls back to UTC when the zone is missing or invalid so boundaries never
|
||||
* silently drift to the server's local zone.
|
||||
*/
|
||||
export const resolveAnalyticsPeriod = ({
|
||||
period,
|
||||
timezone,
|
||||
}: {
|
||||
period: AnalyticsPeriod;
|
||||
timezone?: string;
|
||||
}): AnalyticsPeriodRange => {
|
||||
const zoned = DateTime.now().setZone(timezone ?? 'utc');
|
||||
const now = zoned.isValid ? zoned : DateTime.now().setZone('utc');
|
||||
|
||||
const { start, end } = match(period)
|
||||
.with('week', () => ({
|
||||
start: now.startOf('week'),
|
||||
end: now.startOf('week').plus({ weeks: 1 }),
|
||||
}))
|
||||
.with('month', () => ({
|
||||
start: now.startOf('month'),
|
||||
end: now.startOf('month').plus({ months: 1 }),
|
||||
}))
|
||||
.with('quarter', () => ({
|
||||
start: now.startOf('quarter'),
|
||||
end: now.startOf('quarter').plus({ quarters: 1 }),
|
||||
}))
|
||||
.with('year', () => ({
|
||||
start: now.startOf('year'),
|
||||
end: now.startOf('year').plus({ years: 1 }),
|
||||
}))
|
||||
.with('lastMonth', () => ({
|
||||
start: now.startOf('month').minus({ months: 1 }),
|
||||
end: now.startOf('month'),
|
||||
}))
|
||||
.with('last7Days', () => ({
|
||||
start: now.startOf('day').minus({ days: 6 }),
|
||||
end: now.startOf('day').plus({ days: 1 }),
|
||||
}))
|
||||
.with('last30Days', () => ({
|
||||
start: now.startOf('day').minus({ days: 29 }),
|
||||
end: now.startOf('day').plus({ days: 1 }),
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
return {
|
||||
start: start.toJSDate(),
|
||||
end: end.toJSDate(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||
import type { DB } from '@documenso/prisma/generated/types';
|
||||
import { DocumentStatus, EnvelopeType, TeamMemberRole } from '@prisma/client';
|
||||
import type { ExpressionBuilder, SelectQueryBuilder } from 'kysely';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { getTeamById } from './get-team';
|
||||
|
||||
// Kysely query builder type for Envelope queries.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
|
||||
|
||||
// Expression builder scoped to the Envelope table context.
|
||||
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
|
||||
|
||||
export type GetTeamAnalyticsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
senderIds?: number[];
|
||||
};
|
||||
|
||||
export type TeamAnalytics = {
|
||||
sent: number;
|
||||
draft: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
declined: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute team document-usage analytics for a `[periodStart, periodEnd)` range.
|
||||
*
|
||||
* Each metric counts documents that ENTERED its state during the period, on its
|
||||
* own date axis (see the Documenso team analytics spec):
|
||||
*
|
||||
* - `sent` — non-draft documents created in the period.
|
||||
* - `draft` — documents still in draft, created in the period.
|
||||
* - `pending` — documents still pending, created in the period.
|
||||
* - `completed` — completed documents whose `completedAt` falls in the period.
|
||||
* - `declined` — rejected documents with a `DOCUMENT_RECIPIENT_REJECTED` audit
|
||||
* log entry in the period (there is no `Envelope.rejectedAt`).
|
||||
*
|
||||
* The tiles do NOT sum to `sent`: a document sent in one month but completed the
|
||||
* next lands in that next month's `completed`, never the first month's `sent`.
|
||||
*
|
||||
* Scope mirrors the established team document patterns (`EnvelopeType.DOCUMENT`,
|
||||
* `deletedAt IS NULL`, team `visibilityFilter`) but is limited to documents the
|
||||
* team PRODUCES (`teamId` + owner attribution). Inbox / documents received via a
|
||||
* team email are intentionally excluded. All folders are aggregated. Counts are
|
||||
* exact `COUNT(*)` — the `STATS_COUNT_CAP` used by `getStats` is not applied.
|
||||
*/
|
||||
export const getTeamAnalytics = async ({
|
||||
userId,
|
||||
teamId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
senderIds,
|
||||
}: GetTeamAnalyticsOptions): Promise<TeamAnalytics> => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
|
||||
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
|
||||
|
||||
// Visibility: the viewer can see documents within their allowed visibilities,
|
||||
// documents they own, or documents they are a recipient of.
|
||||
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((visibility) => sql.lit(visibility)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.email', '=', user.email)
|
||||
.select(sql.lit(1).as('one')),
|
||||
),
|
||||
]);
|
||||
|
||||
// Base query: team-produced, non-deleted documents across all folders.
|
||||
const buildBaseQuery = (): EnvelopeQueryBuilder => {
|
||||
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope')
|
||||
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.where('Envelope.teamId', '=', team.id)
|
||||
.where('Envelope.deletedAt', 'is', null)
|
||||
.where(visibilityFilter);
|
||||
|
||||
if (senderIds && senderIds.length > 0) {
|
||||
qb = qb.where('Envelope.userId', 'in', senderIds);
|
||||
}
|
||||
|
||||
return qb;
|
||||
};
|
||||
|
||||
const countEnvelopes = async (qb: EnvelopeQueryBuilder): Promise<number> => {
|
||||
const result = await qb.select(({ fn }) => fn.count<number>('Envelope.id').as('count')).executeTakeFirstOrThrow();
|
||||
|
||||
return Number(result.count ?? 0);
|
||||
};
|
||||
|
||||
// Documents Sent: any non-draft document created in the period.
|
||||
const sentQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT))
|
||||
.where('Envelope.createdAt', '>=', periodStart)
|
||||
.where('Envelope.createdAt', '<', periodEnd);
|
||||
|
||||
// Draft: created in the period, still a draft.
|
||||
const draftQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT))
|
||||
.where('Envelope.createdAt', '>=', periodStart)
|
||||
.where('Envelope.createdAt', '<', periodEnd);
|
||||
|
||||
// Pending: created in the period, still pending.
|
||||
const pendingQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where('Envelope.createdAt', '>=', periodStart)
|
||||
.where('Envelope.createdAt', '<', periodEnd);
|
||||
|
||||
// Completed: completed in the period (completedAt is a distinct date axis).
|
||||
const completedQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where('Envelope.completedAt', '>=', periodStart)
|
||||
.where('Envelope.completedAt', '<', periodEnd);
|
||||
|
||||
// Declined: rejected documents whose rejection was logged in the period.
|
||||
const declinedQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED))
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('DocumentAuditLog')
|
||||
.whereRef('DocumentAuditLog.envelopeId', '=', 'Envelope.id')
|
||||
.where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED)
|
||||
.where('DocumentAuditLog.createdAt', '>=', periodStart)
|
||||
.where('DocumentAuditLog.createdAt', '<', periodEnd)
|
||||
.select(sql.lit(1).as('one')),
|
||||
),
|
||||
);
|
||||
|
||||
const [sent, draft, pending, completed, declined] = await Promise.all([
|
||||
countEnvelopes(sentQuery),
|
||||
countEnvelopes(draftQuery),
|
||||
countEnvelopes(pendingQuery),
|
||||
countEnvelopes(completedQuery),
|
||||
countEnvelopes(declinedQuery),
|
||||
]);
|
||||
|
||||
return {
|
||||
sent,
|
||||
draft,
|
||||
pending,
|
||||
completed,
|
||||
declined,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DEFAULT_ANALYTICS_PERIOD, resolveAnalyticsPeriod } from '@documenso/lib/server-only/team/analytics-period';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getTeamAnalytics } from '@documenso/lib/server-only/team/get-team-analytics';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { ZGetTeamAnalyticsRequestSchema, ZGetTeamAnalyticsResponseSchema } from './get-team-analytics.types';
|
||||
|
||||
export const getTeamAnalyticsRoute = authenticatedProcedure
|
||||
.input(ZGetTeamAnalyticsRequestSchema)
|
||||
.output(ZGetTeamAnalyticsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, period, timezone, senderIds } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
teamId,
|
||||
period,
|
||||
senderIds,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await getTeamById({ userId: user.id, teamId });
|
||||
|
||||
// Analytics are restricted to team admins and managers (MANAGE_TEAM).
|
||||
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You are not allowed to view analytics for this team',
|
||||
});
|
||||
}
|
||||
|
||||
const { start, end } = resolveAnalyticsPeriod({
|
||||
period: period ?? DEFAULT_ANALYTICS_PERIOD,
|
||||
timezone,
|
||||
});
|
||||
|
||||
return await getTeamAnalytics({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
senderIds,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Calendar period presets for the team analytics dashboard.
|
||||
*
|
||||
* Kept structurally in sync with `ZAnalyticsPeriodSchema` in
|
||||
* `@documenso/lib/server-only/team/get-team-analytics`. This schema is duplicated
|
||||
* here (rather than imported) so the request types stay client-safe and never
|
||||
* pull server-only code into the browser bundle. The compiler enforces parity
|
||||
* where the resolved `period` is handed to `resolveAnalyticsPeriod` in the route.
|
||||
*/
|
||||
export const ZAnalyticsPeriodSchema = z.enum([
|
||||
'week',
|
||||
'month',
|
||||
'quarter',
|
||||
'year',
|
||||
'lastMonth',
|
||||
'last7Days',
|
||||
'last30Days',
|
||||
]);
|
||||
|
||||
export const ZGetTeamAnalyticsRequestSchema = z.object({
|
||||
teamId: z.number(),
|
||||
period: ZAnalyticsPeriodSchema.optional(),
|
||||
timezone: z.string().optional(),
|
||||
senderIds: z.array(z.number()).optional(),
|
||||
});
|
||||
|
||||
export const ZGetTeamAnalyticsResponseSchema = z.object({
|
||||
sent: z.number(),
|
||||
draft: z.number(),
|
||||
pending: z.number(),
|
||||
completed: z.number(),
|
||||
declined: z.number(),
|
||||
});
|
||||
|
||||
export type TGetTeamAnalyticsRequest = z.infer<typeof ZGetTeamAnalyticsRequestSchema>;
|
||||
export type TGetTeamAnalyticsResponse = z.infer<typeof ZGetTeamAnalyticsResponseSchema>;
|
||||
@@ -16,6 +16,7 @@ import { findTeamGroupsRoute } from './find-team-groups';
|
||||
import { findTeamMembersRoute } from './find-team-members';
|
||||
import { findTeamsRoute } from './find-teams';
|
||||
import { getTeamRoute } from './get-team';
|
||||
import { getTeamAnalyticsRoute } from './get-team-analytics';
|
||||
import { getTeamMembersRoute } from './get-team-members';
|
||||
import {
|
||||
ZCreateTeamEmailVerificationMutationSchema,
|
||||
@@ -33,6 +34,7 @@ import { updateTeamSettingsRoute } from './update-team-settings';
|
||||
export const teamRouter = router({
|
||||
find: findTeamsRoute,
|
||||
get: getTeamRoute,
|
||||
getAnalytics: getTeamAnalyticsRoute,
|
||||
create: createTeamRoute,
|
||||
update: updateTeamRoute,
|
||||
delete: deleteTeamRoute,
|
||||
|
||||
@@ -142,6 +142,7 @@ export function MultiSelectCombobox<T = OptionValue>({
|
||||
{showClearButton && !loading && (
|
||||
<div className="absolute top-0 right-8 bottom-0 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-muted-foreground/20"
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
|
||||
@@ -431,6 +431,7 @@ const MultiSelect = ({
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -inset-y-px -end-px flex size-7 items-center justify-center rounded-e-md border border-transparent p-0 text-muted-foreground/80 outline-none transition-[color,box-shadow] hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
|
||||
Reference in New Issue
Block a user