+
+ 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.
+
+
+
+ ) : (
+
+
+ No analytics to show yet
+
+
+
+
+ There's no document activity for the selected period. Send your first document to start tracking your
+ team's usage here.
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/app-tests/e2e/teams/team-analytics.spec.ts b/packages/app-tests/e2e/teams/team-analytics.spec.ts
new file mode 100644
index 000000000..a806cf802
--- /dev/null
+++ b/packages/app-tests/e2e/teams/team-analytics.spec.ts
@@ -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.locator('button').filter({ hasText: 'Sender: All' }).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);
+});
diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts
index 1150a4ca7..59606529f 100644
--- a/packages/lib/constants/app.ts
+++ b/packages/lib/constants/app.ts
@@ -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';
diff --git a/packages/lib/server-only/team/analytics-period.test.ts b/packages/lib/server-only/team/analytics-period.test.ts
new file mode 100644
index 000000000..b6cd99b4b
--- /dev/null
+++ b/packages/lib/server-only/team/analytics-period.test.ts
@@ -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);
+ });
+});
diff --git a/packages/lib/server-only/team/analytics-period.ts b/packages/lib/server-only/team/analytics-period.ts
new file mode 100644
index 000000000..c79e2558a
--- /dev/null
+++ b/packages/lib/server-only/team/analytics-period.ts
@@ -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;
+
+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(),
+ };
+};
diff --git a/packages/lib/server-only/team/get-team-analytics.ts b/packages/lib/server-only/team/get-team-analytics.ts
new file mode 100644
index 000000000..8d9c4d0be
--- /dev/null
+++ b/packages/lib/server-only/team/get-team-analytics.ts
@@ -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;
+
+// Expression builder scoped to the Envelope table context.
+type EnvelopeExpressionBuilder = ExpressionBuilder;
+
+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 => {
+ 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 => {
+ const result = await qb.select(({ fn }) => fn.count('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,
+ };
+};
diff --git a/packages/trpc/server/team-router/get-team-analytics.ts b/packages/trpc/server/team-router/get-team-analytics.ts
new file mode 100644
index 000000000..c3fa026f8
--- /dev/null
+++ b/packages/trpc/server/team-router/get-team-analytics.ts
@@ -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,
+ });
+ });
diff --git a/packages/trpc/server/team-router/get-team-analytics.types.ts b/packages/trpc/server/team-router/get-team-analytics.types.ts
new file mode 100644
index 000000000..8a0fd4081
--- /dev/null
+++ b/packages/trpc/server/team-router/get-team-analytics.types.ts
@@ -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;
+export type TGetTeamAnalyticsResponse = z.infer;
diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts
index 26a1d9913..4e32c6d45 100644
--- a/packages/trpc/server/team-router/router.ts
+++ b/packages/trpc/server/team-router/router.ts
@@ -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,
@@ -32,6 +33,7 @@ import { updateTeamSettingsRoute } from './update-team-settings';
export const teamRouter = router({
find: findTeamsRoute,
get: getTeamRoute,
+ getAnalytics: getTeamAnalyticsRoute,
create: createTeamRoute,
update: updateTeamRoute,
delete: deleteTeamRoute,