mirror of
https://github.com/documenso/documenso.git
synced 2026-08-26 00:02:43 +10:00
fix(team): merge main and update analytics filters
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.getByRole('button', { name: 'Sender' }).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);
|
||||
});
|
||||
@@ -53,6 +53,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';
|
||||
|
||||
/**
|
||||
* Whether this instance is Documenso Cloud (managed SaaS).
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
@@ -33,6 +34,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