mirror of
https://github.com/documenso/documenso.git
synced 2026-08-26 00:02:43 +10:00
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.
168 lines
6.1 KiB
TypeScript
168 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
};
|