mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user