diff --git a/packages/lib/server-only/admin/get-organisation-detailed-insights.ts b/packages/lib/server-only/admin/get-organisation-detailed-insights.ts index 342fa6b8e..100c32b31 100644 --- a/packages/lib/server-only/admin/get-organisation-detailed-insights.ts +++ b/packages/lib/server-only/admin/get-organisation-detailed-insights.ts @@ -116,28 +116,28 @@ async function getTeamInsights( ): Promise { 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`COUNT(DISTINCT om."userId")`.as('memberCount'), - (createdAtFrom - ? sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)` - : sql`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`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`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 { - 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`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)` - : sql`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)` - ).as('documentCount'), - (createdAtFrom - ? sql`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`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`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`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 { - const summaryQuery = kyselyPrisma.$kysely - .selectFrom('Organisation as o') - .where('o.id', '=', organisationId) - .select([ - sql`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as( - 'totalTeams', - ), - sql`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as( - 'totalMembers', - ), - sql`( - 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`( - 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`( - 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`( - 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`( - 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`( - 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`count(id)`.as('count')) + .executeTakeFirst(); - const result = await summaryQuery.executeTakeFirst(); + const memberCountQuery = kyselyPrisma.$kysely + .selectFrom('OrganisationMember') + .where('organisationId', '=', organisationId) + .select(sql`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`count(e.id)`.as('totalDocuments'), + sql`count(case when e.status in ('DRAFT', 'PENDING') then 1 end)`.as( + 'activeDocuments', + ), + sql`count(case when e.status = 'COMPLETED' then 1 end)`.as('completedDocuments'), + sql`count(case when e.status = 'COMPLETED' then 1 end)`.as('volumeAllTime'), + (createdAtFrom + ? sql`count(case when e.status = 'COMPLETED' and e."createdAt" >= ${createdAtFrom} then 1 end)` + : sql`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), }; } diff --git a/packages/lib/server-only/admin/get-signing-volume.ts b/packages/lib/server-only/admin/get-signing-volume.ts index 71edce8b5..e85fbf508 100644 --- a/packages/lib/server-only/admin/get-signing-volume.ts +++ b/packages/lib/server-only/admin/get-signing-volume.ts @@ -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`COALESCE(o.name, 'Unknown')`.as('name'), - sql`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`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`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`1=1`; if (startDate && endDate) { - dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`; + dateCondition = sql`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`e."createdAt" >= ${thirtyDaysAgo}`; break; } case 'last90days': { const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); - dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`; + dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`; break; } case 'lastYear': { const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); - dateCondition = sql`e."createdAt" >= ${oneYearAgo}`; + dateCondition = sql`e."createdAt" >= ${oneYearAgo}`; break; } case 'allTime': default: - dateCondition = sql`1=1`; + dateCondition = sql`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`COALESCE(o.name, 'Unknown')`.as('name'), - sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as( - 'signingVolume', - ), - sql`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'), - sql`COUNT(DISTINCT om."userId")`.as('memberCount'), sql`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`count(t.id)`.as('count')) + .as('teamCount'), + eb + .selectFrom('OrganisationMember as om') + .whereRef('om.organisationId', '=', 'o.id') + .select(sql`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`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`COUNT(DISTINCT o.id)`.as('count')]); + .select(({ fn }) => [fn.countAll().as('count')]); const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]); diff --git a/packages/prisma/migrations/20251119022255_add_indexes_for_insights/migration.sql b/packages/prisma/migrations/20251119022255_add_indexes_for_insights/migration.sql new file mode 100644 index 000000000..01c79fbd0 --- /dev/null +++ b/packages/prisma/migrations/20251119022255_add_indexes_for_insights/migration.sql @@ -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"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 09e770e36..315791f1f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -433,6 +433,10 @@ model Envelope { @@index([folderId]) @@index([teamId]) @@index([userId]) + @@index([teamId, deletedAt, type, status]) + @@index([teamId, deletedAt, type, createdAt]) + @@index([userId, deletedAt, type]) + @@index([status, deletedAt, type]) } model EnvelopeItem { @@ -585,6 +589,9 @@ model Recipient { @@index([envelopeId]) @@index([token]) + @@index([email]) + @@index([signedAt]) + @@index([envelopeId, signedAt]) } enum FieldType { @@ -694,6 +701,8 @@ model Organisation { organisationAuthenticationPortalId String @unique organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id]) + + @@index([name]) } model OrganisationMember { @@ -710,6 +719,7 @@ model OrganisationMember { organisationGroupMembers OrganisationGroupMember[] @@unique([userId, organisationId]) + @@index([organisationId]) } model OrganisationMemberInvite { @@ -884,6 +894,7 @@ model Team { teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade) @@index([organisationId]) + @@index([organisationId, name]) } model TeamEmail {