Compare commits

..

1 Commits

Author SHA1 Message Date
e182d29f99 perf: add database indexes for insights queries 2025-11-19 02:26:03 +00:00
6 changed files with 381 additions and 287 deletions

View File

@ -275,15 +275,7 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). | | `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). | | `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). | | `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. | | `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). | | `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). | | `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) | | `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
@ -305,7 +297,6 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. | | `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. | | `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. | | `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. | | `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. | | `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. | | `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
@ -317,7 +308,6 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). | | `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. | | `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. | | `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
## Run as a Service ## Run as a Service

View File

@ -25,6 +25,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error'; import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf'; import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf'; import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
@ -61,120 +62,171 @@ export const run = async ({
}) => { }) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload; const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => { const envelope = await prisma.envelope.findFirstOrThrow({
const envelope = await prisma.envelope.findFirstOrThrow({ where: {
where: { type: EnvelopeType.DOCUMENT,
type: EnvelopeType.DOCUMENT, secondaryId: mapDocumentIdToSecondaryId(documentId),
secondaryId: mapDocumentIdToSecondaryId(documentId), },
}, include: {
include: { documentMeta: true,
documentMeta: true, recipients: true,
recipients: true, envelopeItems: {
envelopeItems: { include: {
include: { documentData: true,
documentData: true, field: {
field: { include: {
include: { signature: true,
signature: true,
},
}, },
}, },
}, },
}, },
},
});
if (envelope.envelopeItems.length === 0) {
throw new Error('At least one envelope item required');
}
const settings = await getTeamSettings({
userId: envelope.userId,
teamId: envelope.teamId,
});
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
}); });
}
if (envelope.envelopeItems.length === 0) { // Seems silly but we need to do this in case the job is re-ran
throw new Error('At least one envelope item required'); // after it has already run through the update task further below.
} // eslint-disable-next-line @typescript-eslint/require-await
const documentStatus = await io.runTask('get-document-status', async () => {
return envelope.status;
});
const settings = await getTeamSettings({ // This is the same case as above.
userId: envelope.userId, let envelopeItems = await io.runTask(
teamId: envelope.teamId, 'get-document-data-id',
}); // eslint-disable-next-line @typescript-eslint/require-await
async () => {
const isComplete = // eslint-disable-next-line unused-imports/no-unused-vars
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) || return envelope.envelopeItems.map(({ field, ...rest }) => ({
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED); ...rest,
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
});
}
let envelopeItems = envelope.envelopeItems;
if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
const recipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
},
});
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const isRejected = Boolean(rejectedRecipient);
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
})); }));
} },
);
if (!envelope.qrToken) { if (envelopeItems.length < 1) {
await prisma.envelope.update({ throw new Error(`Document ${envelope.id} has no envelope items`);
where: { }
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
},
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); const recipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
},
});
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({ // Determine if the document has been rejected by checking if any recipient has rejected it
legacyDocumentId, const rejectedRecipient = recipients.find(
documentMeta: envelope.documentMeta, (recipient) => recipient.signingStatus === SigningStatus.REJECTED,
settings, );
const isRejected = Boolean(rejectedRecipient);
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
}));
}
if (!envelope.qrToken) {
await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
},
}); });
}
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = []; const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
for (const envelopeItem of envelopeItems) { const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
// !: The commented out code is our desired implementation but we're seemingly
// !: running into issues with inngest parallelism in production.
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
// for (const envelopeItem of envelopeItems) {
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
// const envelopeItemFields = envelope.envelopeItems.find(
// (item) => item.id === envelopeItem.id,
// )?.field;
// if (!envelopeItemFields) {
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
// }
// return decorateAndSignPdf({
// envelope,
// envelopeItem,
// envelopeItemFields,
// isRejected,
// rejectionReason,
// certificateData,
// auditLogData,
// });
// });
// decoratePromises.push(task);
// }
// const newDocumentData = await Promise.all(decoratePromises);
// TODO: Remove once parallelization is working
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) {
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find( const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id, (item) => item.id === envelopeItem.id,
)?.field; )?.field;
@ -183,7 +235,7 @@ export const run = async ({
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`); throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
} }
const result = await decorateAndSignPdf({ return decorateAndSignPdf({
envelope, envelope,
envelopeItem, envelopeItem,
envelopeItemFields, envelopeItemFields,
@ -192,10 +244,25 @@ export const run = async ({
certificateData, certificateData,
auditLogData, auditLogData,
}); });
});
newDocumentData.push(result); newDocumentData.push(result);
} }
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: envelope.id,
isRejected,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) { for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({ const newData = await tx.documentData.findFirstOrThrow({
@ -237,24 +304,18 @@ export const run = async ({
}), }),
}); });
}); });
return {
envelopeId: envelope.id,
envelopeStatus: envelope.status,
isRejected,
};
}); });
await io.runTask('send-completed-email', async () => { await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected; let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelopeStatus)) { if (isResealing && !isDocumentCompleted(envelope.status)) {
shouldSendCompletedEmail = sendEmail; shouldSendCompletedEmail = sendEmail;
} }
if (shouldSendCompletedEmail) { if (shouldSendCompletedEmail) {
await sendCompletedEmail({ await sendCompletedEmail({
id: { type: 'envelopeId', id: envelopeId }, id: { type: 'envelopeId', id: envelope.id },
requestMetadata, requestMetadata,
}); });
} }
@ -262,7 +323,7 @@ export const run = async ({
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({ const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { where: {
id: envelopeId, id: envelope.id,
}, },
include: { include: {
documentMeta: true, documentMeta: true,

View File

@ -116,28 +116,28 @@ async function getTeamInsights(
): Promise<OrganisationDetailedInsights> { ): Promise<OrganisationDetailedInsights> {
const teamsQuery = kyselyPrisma.$kysely const teamsQuery = kyselyPrisma.$kysely
.selectFrom('Team as t') .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) .where('t.organisationId', '=', organisationId)
.select([ .select((eb) => [
't.id as id', 't.id',
't.name as name', 't.name',
't.createdAt as createdAt', 't.createdAt',
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'), eb
(createdAtFrom .selectFrom('TeamGroup as tg')
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)` .innerJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
: sql<number>`COUNT(DISTINCT e.id)` .innerJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
).as('documentCount'), .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') .orderBy('documentCount', 'desc')
.limit(perPage) .limit(perPage)
.offset(offset); .offset(offset);
@ -164,48 +164,38 @@ async function getUserInsights(
perPage: number, perPage: number,
createdAtFrom: Date | null, createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> { ): Promise<OrganisationDetailedInsights> {
const usersBase = kyselyPrisma.$kysely const usersQuery = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om') .selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId') .innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId) .where('om.organisationId', '=', organisationId)
.leftJoin('Envelope as e', (join) => .select((eb) => [
join 'u.id',
.onRef('e.userId', '=', 'u.id') 'u.name',
.on('e.deletedAt', 'is', null) 'u.email',
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)), 'u.createdAt',
) eb
.leftJoin('Team as td', (join) => .selectFrom('Envelope as e')
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId), .innerJoin('Team as t', 't.id', 'e.teamId')
) .whereRef('e.userId', '=', 'u.id')
.leftJoin('Recipient as r', (join) => .where('t.organisationId', '=', organisationId)
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null), .where('e.deletedAt', 'is', null)
) .where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.leftJoin('Envelope as se', (join) => .$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
join .select(sql<number>`count(e.id)`.as('count'))
.onRef('se.id', '=', 'r.envelopeId') .as('documentCount'),
.on('se.deletedAt', 'is', null) eb
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)), .selectFrom('Recipient as r')
) .innerJoin('Envelope as e', 'e.id', 'r.envelopeId')
.leftJoin('Team as ts', (join) => .innerJoin('Team as t', 't.id', 'e.teamId')
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId), .whereRef('r.email', '=', 'u.email')
); .where('r.signedAt', 'is not', null)
.where('t.organisationId', '=', organisationId)
const usersQuery = usersBase .where('e.deletedAt', 'is', null)
.select([ .where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
'u.id as id', .$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
'u.name as name', .select(sql<number>`count(e.id)`.as('count'))
'u.email as email', .as('signedDocumentCount'),
'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'),
]) ])
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
.orderBy('u.createdAt', 'desc') .orderBy('u.createdAt', 'desc')
.limit(perPage) .limit(perPage)
.offset(offset); .offset(offset);
@ -292,72 +282,51 @@ async function getOrganisationSummary(
organisationId: string, organisationId: string,
createdAtFrom: Date | null, createdAtFrom: Date | null,
): Promise<OrganisationSummary> { ): Promise<OrganisationSummary> {
const summaryQuery = kyselyPrisma.$kysely const teamCountQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .selectFrom('Team')
.where('o.id', '=', organisationId) .where('organisationId', '=', organisationId)
.select([ .select(sql<number>`count(id)`.as('count'))
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as( .executeTakeFirst();
'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 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 { return {
totalTeams: Number(result?.totalTeams || 0), totalTeams: Number(teamCount?.count || 0),
totalMembers: Number(result?.totalMembers || 0), totalMembers: Number(memberCount?.count || 0),
totalDocuments: Number(result?.totalDocuments || 0), totalDocuments: Number(envelopeStats?.totalDocuments || 0),
activeDocuments: Number(result?.activeDocuments || 0), activeDocuments: Number(envelopeStats?.activeDocuments || 0),
completedDocuments: Number(result?.completedDocuments || 0), completedDocuments: Number(envelopeStats?.completedDocuments || 0),
volumeThisPeriod: Number(result?.volumeThisPeriod || 0), volumeThisPeriod: Number(envelopeStats?.volumeThisPeriod || 0),
volumeAllTime: Number(result?.volumeAllTime || 0), volumeAllTime: Number(envelopeStats?.volumeAllTime || 0),
}; };
} }

View File

@ -33,25 +33,32 @@ export async function getSigningVolume({
let findQuery = kyselyPrisma.$kysely let findQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .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) => .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.id as id',
'o.createdAt as createdAt', 'o.createdAt as createdAt',
'o.customerId as customerId', 'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'), sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'), eb
]) .selectFrom('Envelope as e')
.groupBy(['o.id', 'o.name', 'o.customerId']); .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) { switch (sortBy) {
case 'name': case 'name':
@ -71,11 +78,18 @@ export async function getSigningVolume({
const countQuery = kyselyPrisma.$kysely const countQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where((eb) => .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()]); 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 offset = Math.max(page - 1, 0) * perPage;
const now = new Date(); const now = new Date();
let dateCondition = sql`1=1`; let dateCondition = sql<boolean>`1=1`;
if (startDate && endDate) { if (startDate && endDate) {
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`; dateCondition = sql<boolean>`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
} else { } else {
switch (dateRange) { switch (dateRange) {
case 'last30days': { case 'last30days': {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`; dateCondition = sql<boolean>`e."createdAt" >= ${thirtyDaysAgo}`;
break; break;
} }
case 'last90days': { case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`; dateCondition = sql<boolean>`e."createdAt" >= ${ninetyDaysAgo}`;
break; break;
} }
case 'lastYear': { case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`; dateCondition = sql<boolean>`e."createdAt" >= ${oneYearAgo}`;
break; break;
} }
case 'allTime': case 'allTime':
default: default:
dateCondition = sql`1=1`; dateCondition = sql<boolean>`1=1`;
break; break;
} }
} }
let findQuery = kyselyPrisma.$kysely let findQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .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') .leftJoin('Subscription as s', 'o.id', 's.organisationId')
.where((eb) => .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.id as id',
'o.createdAt as createdAt', 'o.createdAt as createdAt',
'o.customerId as customerId', 'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'), 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( sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
'subscriptionStatus', 'subscriptionStatus',
), ),
]) eb
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']); .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) { switch (sortBy) {
case 'name': case 'name':
@ -181,11 +208,18 @@ export async function getOrganisationInsights({
const countQuery = kyselyPrisma.$kysely const countQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where((eb) => .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()]); const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);

View File

@ -0,0 +1,29 @@
-- CreateIndex
CREATE INDEX "Envelope_teamId_deletedAt_type_status_idx" ON "Envelope"("teamId", "deletedAt", "type", "status");
-- CreateIndex
CREATE INDEX "Envelope_teamId_deletedAt_type_createdAt_idx" ON "Envelope"("teamId", "deletedAt", "type", "createdAt");
-- CreateIndex
CREATE INDEX "Envelope_userId_deletedAt_type_idx" ON "Envelope"("userId", "deletedAt", "type");
-- CreateIndex
CREATE INDEX "Envelope_status_deletedAt_type_idx" ON "Envelope"("status", "deletedAt", "type");
-- CreateIndex
CREATE INDEX "Organisation_name_idx" ON "Organisation"("name");
-- CreateIndex
CREATE INDEX "OrganisationMember_organisationId_idx" ON "OrganisationMember"("organisationId");
-- CreateIndex
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");
-- CreateIndex
CREATE INDEX "Recipient_signedAt_idx" ON "Recipient"("signedAt");
-- CreateIndex
CREATE INDEX "Recipient_envelopeId_signedAt_idx" ON "Recipient"("envelopeId", "signedAt");
-- CreateIndex
CREATE INDEX "Team_organisationId_name_idx" ON "Team"("organisationId", "name");

View File

@ -433,6 +433,10 @@ model Envelope {
@@index([folderId]) @@index([folderId])
@@index([teamId]) @@index([teamId])
@@index([userId]) @@index([userId])
@@index([teamId, deletedAt, type, status])
@@index([teamId, deletedAt, type, createdAt])
@@index([userId, deletedAt, type])
@@index([status, deletedAt, type])
} }
model EnvelopeItem { model EnvelopeItem {
@ -585,6 +589,9 @@ model Recipient {
@@index([envelopeId]) @@index([envelopeId])
@@index([token]) @@index([token])
@@index([email])
@@index([signedAt])
@@index([envelopeId, signedAt])
} }
enum FieldType { enum FieldType {
@ -694,6 +701,8 @@ model Organisation {
organisationAuthenticationPortalId String @unique organisationAuthenticationPortalId String @unique
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id]) organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
@@index([name])
} }
model OrganisationMember { model OrganisationMember {
@ -710,6 +719,7 @@ model OrganisationMember {
organisationGroupMembers OrganisationGroupMember[] organisationGroupMembers OrganisationGroupMember[]
@@unique([userId, organisationId]) @@unique([userId, organisationId])
@@index([organisationId])
} }
model OrganisationMemberInvite { model OrganisationMemberInvite {
@ -884,6 +894,7 @@ model Team {
teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade) teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade)
@@index([organisationId]) @@index([organisationId])
@@index([organisationId, name])
} }
model TeamEmail { model TeamEmail {