feat: team analytics

This commit is contained in:
Ephraim Atta-Duncan
2025-08-20 15:19:09 +00:00
parent a51110d276
commit 202702b1c7
16 changed files with 834 additions and 5 deletions

View File

@ -0,0 +1,68 @@
import type { DocumentStatus, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type {
AnalyticsFilters,
DocumentStats,
TeamDocumentStatsFilters,
UserDocumentStatsFilters,
} from './types';
export const getDocumentStats = async (filters: AnalyticsFilters): Promise<DocumentStats> => {
const { dateFrom, dateTo, ...entityFilter } = filters;
const where: Prisma.DocumentWhereInput = {
...entityFilter,
deletedAt: null,
...(dateFrom || dateTo
? {
createdAt: {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
},
}
: {}),
};
const counts = await prisma.document.groupBy({
by: ['status'],
where,
_count: {
_all: true,
},
});
const stats: DocumentStats = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
counts.forEach((stat) => {
stats[stat.status as DocumentStatus] = stat._count._all;
stats.ALL += stat._count._all;
});
return stats;
};
// Legacy wrapper functions for backwards compatibility
export const getTeamDocumentStats = async (filters: TeamDocumentStatsFilters) => {
return getDocumentStats({
teamId: filters.teamId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};
export const getUserDocumentStats = async (filters: UserDocumentStatsFilters) => {
return getDocumentStats({
userId: filters.userId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};

View File

@ -0,0 +1,80 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { MonthlyStats } from './types';
type MonthlyDocumentGrowthQueryResult = Array<{
month: Date;
count: bigint;
signed_count: bigint;
}>;
type MonthlyActiveUsersQueryResult = Array<{
month: Date;
count: bigint;
cume_count: bigint;
}>;
export const getTeamMonthlyDocumentGrowth = async (teamId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."id") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
FROM "Document"
WHERE "Document"."teamId" = ${teamId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
signed_count: Number(row.signed_count),
}));
};
export const getUserMonthlyDocumentGrowth = async (userId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."id") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
FROM "Document"
WHERE "Document"."userId" = ${userId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
signed_count: Number(row.signed_count),
}));
};
export const getTeamMonthlyActiveUsers = async (teamId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyActiveUsersQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."userId") as "count",
SUM(COUNT(DISTINCT "Document"."userId")) OVER (ORDER BY DATE_TRUNC('month', "Document"."createdAt")) as "cume_count"
FROM "Document"
WHERE "Document"."teamId" = ${teamId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
cume_count: Number(row.cume_count),
}));
};

View File

@ -0,0 +1,75 @@
import { type Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type {
AnalyticsFilters,
RecipientStats,
TeamRecipientsStatsFilters,
UserRecipientsStatsFilters,
} from './types';
export const getRecipientStats = async (filters: AnalyticsFilters): Promise<RecipientStats> => {
const { dateFrom, dateTo, ...entityFilter } = filters;
const where: Prisma.RecipientWhereInput = {
document: {
...entityFilter,
deletedAt: null,
...(dateFrom || dateTo
? {
createdAt: {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
},
}
: {}),
},
};
const results = await prisma.recipient.groupBy({
by: ['readStatus', 'signingStatus', 'sendStatus'],
where,
_count: true,
});
const stats: RecipientStats = {
TOTAL_RECIPIENTS: 0,
[ReadStatus.OPENED]: 0,
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};
results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus] += _count;
stats[signingStatus] += _count;
stats[sendStatus] += _count;
stats.TOTAL_RECIPIENTS += _count;
});
return stats;
};
// Legacy wrapper functions for backwards compatibility
export const getTeamRecipientsStats = async (filters: TeamRecipientsStatsFilters) => {
return getRecipientStats({
teamId: filters.teamId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};
export const getUserRecipientsStats = async (filters: UserRecipientsStatsFilters) => {
return getRecipientStats({
userId: filters.userId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};

View File

@ -0,0 +1,4 @@
export * from './types';
export * from './get-document-stats';
export * from './get-recipient-stats';
export * from './get-monthly-stats';

View File

@ -0,0 +1,63 @@
import type { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type EntityFilter = { teamId: number } | { userId: number };
export type AnalyticsDateFilter = {
dateFrom?: Date;
dateTo?: Date;
};
export type AnalyticsFilters = EntityFilter & AnalyticsDateFilter;
export type DocumentStats = Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number>;
export type RecipientStats = {
TOTAL_RECIPIENTS: number;
[ReadStatus.OPENED]: number;
[ReadStatus.NOT_OPENED]: number;
[SigningStatus.SIGNED]: number;
[SigningStatus.NOT_SIGNED]: number;
[SigningStatus.REJECTED]: number;
[SendStatus.SENT]: number;
[SendStatus.NOT_SENT]: number;
};
export type MonthlyStats = Array<{
month: string;
count: number;
signed_count?: number;
cume_count?: number;
}>;
export type UserMonthlyDocumentGrowth = MonthlyStats;
export type TeamMonthlyDocumentGrowth = MonthlyStats;
export type TeamMonthlyActiveUsers = MonthlyStats;
// Legacy type exports for backwards compatibility
export type TeamDocumentStatsFilters = {
teamId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type UserDocumentStatsFilters = {
userId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type TeamRecipientsStatsFilters = {
teamId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type UserRecipientsStatsFilters = {
userId: number;
dateFrom?: Date;
dateTo?: Date;
};