fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-11-27 03:19:15 +00:00
175 changed files with 30748 additions and 71809 deletions
@@ -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`);
}
};