feat(team): add team analytics dashboard

Add a team document-usage dashboard at /t/:teamUrl/analytics for team admins and managers,
behind the NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED rollout flag (enabled by default, set to
"false" to gate it off).

Backend:
- getTeamAnalytics Kysely query over team-produced documents across all folders, with exact
  COUNT(*) (no STATS_COUNT_CAP). Each metric uses its own date axis: Sent/Draft/Pending by
  createdAt, Completed by Envelope.completedAt, Declined by the DOCUMENT_RECIPIENT_REJECTED
  audit-log timestamp.
- resolveAnalyticsPeriod turns calendar presets into half-open [start, end) ranges in the
  viewer's timezone, falling back to UTC.
- team.getAnalytics tRPC route gated to ADMIN/MANAGER.

Frontend:
- Standalone /t/:teamUrl/analytics route whose loader gates the flag and role, silently
  redirecting members to documents.
- Headline metrics and compact stat tiles, a member multiselect filter, a calendar-preset
  period selector, and an empty state.
- Role- and flag-gated nav entries in the desktop and mobile navigation.

Tests:
- Unit tests for the period resolver (timezone and preset boundaries).
- Integration/E2E tests for the query semantics (date axes, audit-log decline, all-folders
  aggregation, sender attribution), access control, filters and the empty state.
This commit is contained in:
ephraimduncan
2026-05-29 13:52:29 +00:00
parent 22ceff43e3
commit ae07df6061
14 changed files with 1002 additions and 3 deletions
+9
View File
@@ -15,6 +15,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';
export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
@@ -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,
};
};