mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 09:25:08 +10:00
fix: merge conflicts
This commit is contained in:
@@ -116,28 +116,28 @@ async function getTeamInsights(
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
const teamsQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Team as t')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
|
||||
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
|
||||
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
|
||||
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.select([
|
||||
't.id as id',
|
||||
't.name as name',
|
||||
't.createdAt as createdAt',
|
||||
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT e.id)`
|
||||
).as('documentCount'),
|
||||
.select((eb) => [
|
||||
't.id',
|
||||
't.name',
|
||||
't.createdAt',
|
||||
eb
|
||||
.selectFrom('TeamGroup as tg')
|
||||
.innerJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
|
||||
.innerJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
|
||||
.innerJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
|
||||
.whereRef('tg.teamId', '=', 't.id')
|
||||
.select(sql<number>`count(distinct om."userId")`.as('count'))
|
||||
.as('memberCount'),
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.whereRef('e.teamId', '=', 't.id')
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('documentCount'),
|
||||
])
|
||||
.groupBy(['t.id', 't.name', 't.createdAt'])
|
||||
.orderBy('documentCount', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
@@ -164,48 +164,38 @@ async function getUserInsights(
|
||||
perPage: number,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
const usersBase = kyselyPrisma.$kysely
|
||||
const usersQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('OrganisationMember as om')
|
||||
.innerJoin('User as u', 'u.id', 'om.userId')
|
||||
.where('om.organisationId', '=', organisationId)
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('e.userId', '=', 'u.id')
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('Team as td', (join) =>
|
||||
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
|
||||
)
|
||||
.leftJoin('Recipient as r', (join) =>
|
||||
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
|
||||
)
|
||||
.leftJoin('Envelope as se', (join) =>
|
||||
join
|
||||
.onRef('se.id', '=', 'r.envelopeId')
|
||||
.on('se.deletedAt', 'is', null)
|
||||
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('Team as ts', (join) =>
|
||||
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
|
||||
);
|
||||
|
||||
const usersQuery = usersBase
|
||||
.select([
|
||||
'u.id as id',
|
||||
'u.name as name',
|
||||
'u.email as email',
|
||||
'u.createdAt as createdAt',
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
|
||||
).as('documentCount'),
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
|
||||
).as('signedDocumentCount'),
|
||||
.select((eb) => [
|
||||
'u.id',
|
||||
'u.name',
|
||||
'u.email',
|
||||
'u.createdAt',
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('e.userId', '=', 'u.id')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('documentCount'),
|
||||
eb
|
||||
.selectFrom('Recipient as r')
|
||||
.innerJoin('Envelope as e', 'e.id', 'r.envelopeId')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('r.email', '=', 'u.email')
|
||||
.where('r.signedAt', 'is not', null)
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signedDocumentCount'),
|
||||
])
|
||||
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
|
||||
.orderBy('u.createdAt', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
@@ -292,72 +282,51 @@ async function getOrganisationSummary(
|
||||
organisationId: string,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationSummary> {
|
||||
const summaryQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.where('o.id', '=', organisationId)
|
||||
.select([
|
||||
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
|
||||
'totalTeams',
|
||||
),
|
||||
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
|
||||
'totalMembers',
|
||||
),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
|
||||
)`.as('totalDocuments'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
|
||||
)`.as('activeDocuments'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||
)`.as('completedDocuments'),
|
||||
(createdAtFrom
|
||||
? sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id
|
||||
AND e2."deletedAt" IS NULL
|
||||
AND e2.type = 'DOCUMENT'
|
||||
AND e2.status = 'COMPLETED'
|
||||
AND e2."createdAt" >= ${createdAtFrom}
|
||||
)`
|
||||
: sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id
|
||||
AND e2."deletedAt" IS NULL
|
||||
AND e2.type = 'DOCUMENT'
|
||||
AND e2.status = 'COMPLETED'
|
||||
)`
|
||||
).as('volumeThisPeriod'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||
)`.as('volumeAllTime'),
|
||||
]);
|
||||
const teamCountQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Team')
|
||||
.where('organisationId', '=', organisationId)
|
||||
.select(sql<number>`count(id)`.as('count'))
|
||||
.executeTakeFirst();
|
||||
|
||||
const result = await summaryQuery.executeTakeFirst();
|
||||
const memberCountQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('OrganisationMember')
|
||||
.where('organisationId', '=', organisationId)
|
||||
.select(sql<number>`count(id)`.as('count'))
|
||||
.executeTakeFirst();
|
||||
|
||||
const envelopeStatsQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.select([
|
||||
sql<number>`count(e.id)`.as('totalDocuments'),
|
||||
sql<number>`count(case when e.status in ('DRAFT', 'PENDING') then 1 end)`.as(
|
||||
'activeDocuments',
|
||||
),
|
||||
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('completedDocuments'),
|
||||
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('volumeAllTime'),
|
||||
(createdAtFrom
|
||||
? sql<number>`count(case when e.status = 'COMPLETED' and e."createdAt" >= ${createdAtFrom} then 1 end)`
|
||||
: sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`
|
||||
).as('volumeThisPeriod'),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
const [teamCount, memberCount, envelopeStats] = await Promise.all([
|
||||
teamCountQuery,
|
||||
memberCountQuery,
|
||||
envelopeStatsQuery,
|
||||
]);
|
||||
|
||||
return {
|
||||
totalTeams: Number(result?.totalTeams || 0),
|
||||
totalMembers: Number(result?.totalMembers || 0),
|
||||
totalDocuments: Number(result?.totalDocuments || 0),
|
||||
activeDocuments: Number(result?.activeDocuments || 0),
|
||||
completedDocuments: Number(result?.completedDocuments || 0),
|
||||
volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
|
||||
volumeAllTime: Number(result?.volumeAllTime || 0),
|
||||
totalTeams: Number(teamCount?.count || 0),
|
||||
totalMembers: Number(memberCount?.count || 0),
|
||||
totalDocuments: Number(envelopeStats?.totalDocuments || 0),
|
||||
activeDocuments: Number(envelopeStats?.activeDocuments || 0),
|
||||
completedDocuments: Number(envelopeStats?.completedDocuments || 0),
|
||||
volumeThisPeriod: Number(envelopeStats?.volumeThisPeriod || 0),
|
||||
volumeAllTime: Number(envelopeStats?.volumeAllTime || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,25 +33,32 @@ export async function getSigningVolume({
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.select([
|
||||
.select((eb) => [
|
||||
'o.id as id',
|
||||
'o.createdAt as createdAt',
|
||||
'o.customerId as customerId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['o.id', 'o.name', 'o.customerId']);
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signingVolume'),
|
||||
]);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@@ -71,11 +78,18 @@ export async function getSigningVolume({
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
@@ -104,64 +118,77 @@ export async function getOrganisationInsights({
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const now = new Date();
|
||||
let dateCondition = sql`1=1`;
|
||||
let dateCondition = sql<boolean>`1=1`;
|
||||
|
||||
if (startDate && endDate) {
|
||||
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
|
||||
} else {
|
||||
switch (dateRange) {
|
||||
case 'last30days': {
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${thirtyDaysAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'last90days': {
|
||||
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${ninetyDaysAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'lastYear': {
|
||||
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${oneYearAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'allTime':
|
||||
default:
|
||||
dateCondition = sql`1=1`;
|
||||
dateCondition = sql<boolean>`1=1`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
|
||||
.leftJoin('Subscription as s', 'o.id', 's.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.select([
|
||||
.select((eb) => [
|
||||
'o.id as id',
|
||||
'o.createdAt as createdAt',
|
||||
'o.customerId as customerId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
|
||||
'signingVolume',
|
||||
),
|
||||
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
|
||||
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
|
||||
'subscriptionStatus',
|
||||
),
|
||||
])
|
||||
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.select(sql<number>`count(t.id)`.as('count'))
|
||||
.as('teamCount'),
|
||||
eb
|
||||
.selectFrom('OrganisationMember as om')
|
||||
.whereRef('om.organisationId', '=', 'o.id')
|
||||
.select(sql<number>`count(om.id)`.as('count'))
|
||||
.as('memberCount'),
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.where(dateCondition)
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signingVolume'),
|
||||
]);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@@ -181,11 +208,18 @@ export async function getOrganisationInsights({
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const getMonthlyActiveUsers = async () => {
|
||||
)
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||
.where(() => sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
@@ -103,6 +103,7 @@ export const getDocumentAndSenderByToken = async ({
|
||||
select: {
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
url: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
brandingEnabled: true,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export default function PostHogServerClient() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
if (!postHogConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PostHog(postHogConfig.key, {
|
||||
host: postHogConfig.host,
|
||||
fetch: async (...args) => fetch(...args),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
USE_INTERNAL_URL_BROWSERLESS,
|
||||
} from '../../constants/app';
|
||||
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||
import { env } from '../../utils/env';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
@@ -48,14 +52,19 @@ export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfO
|
||||
{
|
||||
name: 'language',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
url: USE_INTERNAL_URL_BROWSERLESS()
|
||||
? NEXT_PUBLIC_WEBAPP_URL()
|
||||
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.goto(
|
||||
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`,
|
||||
{
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// !: This is a workaround to ensure the page is loaded correctly.
|
||||
// !: It's not clear why but suddenly browserless cdp connections would
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
USE_INTERNAL_URL_BROWSERLESS,
|
||||
} from '../../constants/app';
|
||||
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||
import { env } from '../../utils/env';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
@@ -48,14 +52,19 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
|
||||
{
|
||||
name: 'lang',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
url: USE_INTERNAL_URL_BROWSERLESS()
|
||||
? NEXT_PUBLIC_WEBAPP_URL()
|
||||
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.goto(
|
||||
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`,
|
||||
{
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// !: This is a workaround to ensure the page is loaded correctly.
|
||||
// !: It's not clear why but suddenly browserless cdp connections would
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TSiteSettingSchema } from './schema';
|
||||
import { ZSiteSettingSchema } from './schema';
|
||||
|
||||
export const getSiteSetting = async <
|
||||
T extends TSiteSettingSchema['id'],
|
||||
U = Extract<TSiteSettingSchema, { id: T }>,
|
||||
>(options: {
|
||||
id: T;
|
||||
}): Promise<U> => {
|
||||
const { id } = options;
|
||||
|
||||
const setting = await prisma.siteSettings.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ZSiteSettingSchema.parse(setting) as U;
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSiteSettingsBannerSchema } from './schemas/banner';
|
||||
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
|
||||
|
||||
// TODO: Use `z.union([...])` once we have more than one setting
|
||||
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
|
||||
export const ZSiteSettingSchema = z.union([
|
||||
ZSiteSettingsBannerSchema,
|
||||
ZSiteSettingsTelemetrySchema,
|
||||
]);
|
||||
|
||||
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSiteSettingsBaseSchema } from './_base';
|
||||
|
||||
export const SITE_SETTINGS_TELEMETRY_ID = 'telemetry.installation';
|
||||
|
||||
export const ZSiteSettingsTelemetrySchema = ZSiteSettingsBaseSchema.extend({
|
||||
id: z.literal(SITE_SETTINGS_TELEMETRY_ID),
|
||||
data: z.object({
|
||||
installationId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TSiteSettingsTelemetrySchema = z.infer<typeof ZSiteSettingsTelemetrySchema>;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TSiteSettingSchema } from './schema';
|
||||
import { type TSiteSettingSchema } from './schema';
|
||||
|
||||
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
|
||||
userId: number;
|
||||
userId?: number | null;
|
||||
};
|
||||
|
||||
export const upsertSiteSetting = async ({
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/* eslint-disable require-atomic-updates */
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
import { version } from '../../../../package.json';
|
||||
import { prefixedId } from '../../universal/id';
|
||||
import { getSiteSetting } from '../site-settings/get-site-setting';
|
||||
import { SITE_SETTINGS_TELEMETRY_ID } from '../site-settings/schemas/telemetry';
|
||||
import { upsertSiteSetting } from '../site-settings/upsert-site-setting';
|
||||
|
||||
const TELEMETRY_KEY = process.env.NEXT_PRIVATE_TELEMETRY_KEY;
|
||||
const TELEMETRY_HOST = process.env.NEXT_PRIVATE_TELEMETRY_HOST;
|
||||
const TELEMETRY_DISABLED = !!process.env.DOCUMENSO_DISABLE_TELEMETRY;
|
||||
|
||||
const NODE_ID_FILENAME = '.documenso-node-id';
|
||||
const HEARTBEAT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Version is hardcoded to avoid rollup JSON import issues
|
||||
const APP_VERSION = version;
|
||||
|
||||
export class TelemetryClient {
|
||||
private static instance: TelemetryClient | null = null;
|
||||
|
||||
private client: PostHog | null = null;
|
||||
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
private installationId: string | null = null;
|
||||
private nodeId: string | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Start the telemetry client.
|
||||
*
|
||||
* This will initialize the PostHog client, load or create the installation ID and node ID,
|
||||
* capture a startup event, and start a heartbeat interval.
|
||||
*
|
||||
* If telemetry is disabled via `DOCUMENSO_DISABLE_TELEMETRY=true` or credentials are not
|
||||
* provided, this will be a no-op.
|
||||
*/
|
||||
public static async start(): Promise<void> {
|
||||
if (TELEMETRY_DISABLED) {
|
||||
console.log(
|
||||
'[Telemetry] Telemetry is disabled. To enable, remove the DOCUMENSO_DISABLE_TELEMETRY environment variable.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TELEMETRY_KEY || !TELEMETRY_HOST) {
|
||||
console.log('[Telemetry] Telemetry credentials not configured. Telemetry will not be sent.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (TelemetryClient.instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = new TelemetryClient();
|
||||
|
||||
TelemetryClient.instance = instance;
|
||||
|
||||
await instance.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the telemetry client.
|
||||
*
|
||||
* This will clear the heartbeat interval and shutdown the PostHog client.
|
||||
*/
|
||||
public static async stop(): Promise<void> {
|
||||
const instance = TelemetryClient.instance;
|
||||
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (instance.heartbeatInterval) {
|
||||
clearInterval(instance.heartbeatInterval);
|
||||
}
|
||||
|
||||
if (instance.client) {
|
||||
await instance.client.shutdown();
|
||||
}
|
||||
|
||||
TelemetryClient.instance = null;
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
this.client = new PostHog(TELEMETRY_KEY!, {
|
||||
host: TELEMETRY_HOST,
|
||||
disableGeoip: false,
|
||||
});
|
||||
|
||||
// Load or create IDs
|
||||
this.installationId = await this.getOrCreateInstallationId();
|
||||
this.nodeId = await this.getOrCreateNodeId();
|
||||
|
||||
console.log(
|
||||
'[Telemetry] Telemetry is enabled. Documenso collects anonymous usage data to help improve the product.',
|
||||
);
|
||||
console.log(
|
||||
'[Telemetry] We collect: app version, installation ID, and node ID. No personal data, document contents, or user information is collected.',
|
||||
);
|
||||
console.log(
|
||||
'[Telemetry] To disable telemetry, set DOCUMENSO_DISABLE_TELEMETRY=true in your environment variables.',
|
||||
);
|
||||
console.log(
|
||||
'[Telemetry] Learn more: https://documenso.com/docs/developers/self-hosting/telemetry',
|
||||
);
|
||||
|
||||
// Capture startup event
|
||||
this.captureEvent('telemetry_selfhoster_startup');
|
||||
|
||||
// Start heartbeat
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
this.captureEvent('telemetry_selfhoster_heartbeat');
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private captureEvent(event: string): void {
|
||||
if (!this.client || !this.installationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.client.capture({
|
||||
distinctId: this.installationId,
|
||||
event,
|
||||
properties: {
|
||||
appVersion: APP_VERSION,
|
||||
installationId: this.installationId,
|
||||
nodeId: this.nodeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getOrCreateInstallationId(): Promise<string> {
|
||||
try {
|
||||
// Try to get from site settings
|
||||
const existing = await getSiteSetting({ id: SITE_SETTINGS_TELEMETRY_ID }).catch(() => null);
|
||||
|
||||
if (existing) {
|
||||
if (existing.data.installationId) {
|
||||
return existing.data.installationId;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new installation ID
|
||||
const installationId = prefixedId('installation');
|
||||
|
||||
await upsertSiteSetting({
|
||||
id: SITE_SETTINGS_TELEMETRY_ID,
|
||||
data: { installationId },
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return installationId;
|
||||
} catch {
|
||||
// If database is not available, generate a temporary ID
|
||||
return prefixedId('installation');
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateNodeId(): Promise<string | null> {
|
||||
const nodeIdPath = path.join(os.tmpdir(), NODE_ID_FILENAME);
|
||||
|
||||
try {
|
||||
const existingId = await fs.readFile(nodeIdPath, 'utf-8');
|
||||
|
||||
if (existingId.trim()) {
|
||||
return existingId.trim();
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or can't be read, continue to create
|
||||
}
|
||||
|
||||
// Generate new node ID
|
||||
const nodeId = prefixedId('node');
|
||||
|
||||
try {
|
||||
await fs.writeFile(nodeIdPath, nodeId, 'utf-8');
|
||||
} catch {
|
||||
// Read-only filesystem, use memory for nodeId
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type HandlerTriggerWebhooksResponse =
|
||||
error: string;
|
||||
};
|
||||
|
||||
// Todo: [Webhooks] delete after deployment.
|
||||
export const handlerTriggerWebhooks = async (req: Request) => {
|
||||
const signature = req.headers.get('x-webhook-signature');
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../../constants/app';
|
||||
import { sign } from '../../crypto/sign';
|
||||
import { jobs } from '../../../jobs/client';
|
||||
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
||||
|
||||
export type TriggerWebhookOptions = {
|
||||
@@ -13,35 +12,26 @@ export type TriggerWebhookOptions = {
|
||||
|
||||
export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => {
|
||||
try {
|
||||
const body = {
|
||||
event,
|
||||
data,
|
||||
userId,
|
||||
teamId,
|
||||
};
|
||||
|
||||
const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
|
||||
|
||||
if (registeredWebhooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = sign(body);
|
||||
|
||||
await Promise.race([
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/webhook/trigger`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-webhook-signature': signature,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
await Promise.allSettled(
|
||||
registeredWebhooks.map(async (webhook) => {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.execute-webhook',
|
||||
payload: {
|
||||
event,
|
||||
webhookId: webhook.id,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Request timeout')), 500);
|
||||
}),
|
||||
]).catch(() => null);
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(`Failed to trigger webhook`);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user