mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 19:51:32 +10:00
Compare commits
1 Commits
docs/self-
...
perf/insig
| Author | SHA1 | Date | |
|---|---|---|---|
| e182d29f99 |
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()]);
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user