mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 00:43:40 +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,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,
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user