mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 18:21:32 +10:00
feat: team analytics
This commit is contained in:
68
packages/lib/server-only/analytics/get-document-stats.ts
Normal file
68
packages/lib/server-only/analytics/get-document-stats.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
80
packages/lib/server-only/analytics/get-monthly-stats.ts
Normal file
80
packages/lib/server-only/analytics/get-monthly-stats.ts
Normal 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),
|
||||
}));
|
||||
};
|
||||
75
packages/lib/server-only/analytics/get-recipient-stats.ts
Normal file
75
packages/lib/server-only/analytics/get-recipient-stats.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
4
packages/lib/server-only/analytics/index.ts
Normal file
4
packages/lib/server-only/analytics/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './get-document-stats';
|
||||
export * from './get-recipient-stats';
|
||||
export * from './get-monthly-stats';
|
||||
63
packages/lib/server-only/analytics/types.ts
Normal file
63
packages/lib/server-only/analytics/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user