Compare commits

..

5 Commits

Author SHA1 Message Date
Crowdin Bot
5b77e90c3f chore: add translations 2025-11-27 04:05:42 +00:00
Ephraim Duncan
5d8b147199 fix: delay field tooltip scroll on envelope item switch (#2246) 2025-11-27 13:37:33 +11:00
Filbert Wijaya
7d28295d42 build: remove unsupported auto-install-peers from .npmrc (#2199) 2025-11-27 13:35:23 +11:00
Ephraim Duncan
94646cd48a perf: add database indexes for insights queries (#2211) 2025-11-26 21:21:01 +11:00
Ephraim Duncan
14db9b8203 feat: add navigation links between admin org pages (#2243) 2025-11-26 15:15:29 +11:00
13 changed files with 253 additions and 193 deletions

1
.npmrc
View File

@@ -1,3 +1,2 @@
auto-install-peers = true
legacy-peer-deps = true legacy-peer-deps = true
prefer-dedupe = true prefer-dedupe = true

View File

@@ -135,7 +135,7 @@ export const DocumentSigningForm = ({
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10" className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary" variant="secondary"
size="lg" size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1} disabled={typeof window !== 'undefined' && window.history.length <= 1}
@@ -166,7 +166,7 @@ export const DocumentSigningForm = ({
) : recipient.role === RecipientRole.ASSISTANT ? ( ) : recipient.role === RecipientRole.ASSISTANT ? (
<> <>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}> <form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3"> <fieldset className="rounded-2xl border border-border bg-white p-3 dark:bg-background">
<Controller <Controller
name="selectedSignerId" name="selectedSignerId"
control={assistantForm.control} control={assistantForm.control}
@@ -185,7 +185,7 @@ export const DocumentSigningForm = ({
.map((r) => ( .map((r) => (
<div <div
key={`${assistantSignersId}-${r.id}`} key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4" className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -203,15 +203,15 @@ export const DocumentSigningForm = ({
{r.name} {r.name}
{r.id === recipient.id && ( {r.id === recipient.id && (
<span className="text-muted-foreground ml-2"> <span className="ml-2 text-muted-foreground">
{_(msg`(You)`)} {_(msg`(You)`)}
</span> </span>
)} )}
</Label> </Label>
<p className="text-muted-foreground text-xs">{r.email}</p> <p className="text-xs text-muted-foreground">{r.email}</p>
</div> </div>
</div> </div>
<div className="text-muted-foreground text-xs leading-[inherit]"> <div className="text-xs leading-[inherit] text-muted-foreground">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'} {r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div> </div>
</div> </div>
@@ -265,7 +265,7 @@ export const DocumentSigningForm = ({
<Input <Input
type="text" type="text"
id="full-name" id="full-name"
className="bg-background mt-2" className="mt-2 bg-background"
value={fullName} value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())} onChange={(e) => setFullName(e.target.value.trimStart())}
/> />
@@ -294,7 +294,7 @@ export const DocumentSigningForm = ({
<div className="mt-6 flex flex-col gap-4 md:flex-row"> <div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10" className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary" variant="secondary"
size="lg" size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1} disabled={typeof window !== 'undefined' && window.history.length <= 1}

View File

@@ -57,17 +57,24 @@ export const EnvelopeSignerCompleteDialog = () => {
return; return;
} }
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) { const isEnvelopeItemSwitch = nextField.envelopeItemId !== currentEnvelopeItem?.id;
if (isEnvelopeItemSwitch) {
setCurrentEnvelopeItem(nextField.envelopeItemId); setCurrentEnvelopeItem(nextField.envelopeItemId);
} }
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
setTimeout(
() => {
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
},
isEnvelopeItemSwitch ? 150 : 50,
);
}; };
const handleOnCompleteClick = async ( const handleOnCompleteClick = async (

View File

@@ -1,6 +1,10 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights'; import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
import type { DateRange } from '@documenso/lib/types/search-params'; import type { DateRange } from '@documenso/lib/types/search-params';
import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation'; import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
import { Button } from '@documenso/ui/primitives/button';
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table'; import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
@@ -38,12 +42,17 @@ export async function loader({ params, request }: Route.LoaderArgs) {
} }
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) { export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
const { insights, page, perPage, dateRange, view, organisationName } = loaderData; const { insights, page, perPage, dateRange, view, organisationName, organisationId } = loaderData;
return ( return (
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">{organisationName}</h2> <h2 className="text-4xl font-semibold">{organisationName}</h2>
<Button variant="outline" asChild>
<Link to={`/admin/organisations/${organisationId}`}>
<Trans>Manage organisation</Trans>
</Link>
</Button>
</div> </div>
<div className="mt-8"> <div className="mt-8">
<OrganisationInsightsTable <OrganisationInsightsTable

View File

@@ -44,7 +44,7 @@ export async function loader({ request }: Route.LoaderArgs) {
const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({ const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
id: String(item.id), id: String(item.id),
name: item.name || '', name: item.name || '',
signingVolume: item.signingVolume, signingVolume: item.signingVolume || 0,
createdAt: item.createdAt || new Date(), createdAt: item.createdAt || new Date(),
customerId: item.customerId || '', customerId: item.customerId || '',
subscriptionStatus: item.subscriptionStatus, subscriptionStatus: item.subscriptionStatus,

View File

@@ -162,7 +162,13 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
<SettingsHeader <SettingsHeader
title={t`Manage organisation`} title={t`Manage organisation`}
subtitle={t`Manage the ${organisation.name} organisation`} subtitle={t`Manage the ${organisation.name} organisation`}
/> >
<Button variant="outline" asChild>
<Link to={`/admin/organisation-insights/${organisationId}`}>
<Trans>View insights</Trans>
</Link>
</Button>
</SettingsHeader>
<GenericOrganisationAdminForm organisation={organisation} /> <GenericOrganisationAdminForm organisation={organisation} />

View File

@@ -20,7 +20,7 @@
"commitlint": "commitlint --edit", "commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules", "clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run translate:compile && npm run dev", "d": "npm run dx && npm run translate:compile && npm run dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed", "dx": "npm ci && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
"dx:up": "docker compose -f docker/development/compose.yml up -d", "dx:up": "docker compose -f docker/development/compose.yml up -d",
"dx:down": "docker compose -f docker/development/compose.yml down", "dx:down": "docker compose -f docker/development/compose.yml down",
"ci": "turbo run build --filter=@documenso/remix && turbo run test:e2e", "ci": "turbo run build --filter=@documenso/remix && turbo run test:e2e",

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

@@ -8,7 +8,7 @@ msgstr ""
"Language: nl\n" "Language: nl\n"
"Project-Id-Version: documenso-app\n" "Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-11-12 06:14\n" "PO-Revision-Date: 2025-11-27 04:05\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Dutch\n" "Language-Team: Dutch\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -425,7 +425,7 @@ msgstr "{teamName} heeft je uitgenodigd om {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx #: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "{userAgent}" msgid "{userAgent}"
msgstr "" msgstr "{userAgent}"
#: packages/lib/utils/document-audit-logs.ts #: packages/lib/utils/document-audit-logs.ts
msgid "{userName} approved the document" msgid "{userName} approved the document"
@@ -8897,7 +8897,7 @@ msgstr "Dit zal de status van alle e-maildomeinen voor deze organisatie controle
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported"
msgstr "" msgstr "Dit zal ALLEEN featurevlaggen terugporten die op true zijn ingesteld; alles wat in de initiële aanvraag is uitgeschakeld, wordt niet teruggeport."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
msgid "This will remove all emails associated with this email domain" msgid "This will remove all emails associated with this email domain"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n" "Language: pl\n"
"Project-Id-Version: documenso-app\n" "Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-11-20 02:32\n" "PO-Revision-Date: 2025-11-21 00:14\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Polish\n" "Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@@ -7584,7 +7584,7 @@ msgstr "Liczba podpisów"
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx #: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
#: packages/ui/components/document/document-read-only-fields.tsx #: packages/ui/components/document/document-read-only-fields.tsx
msgid "Signed" msgid "Signed"
msgstr "Podpisał" msgstr "Podpisano"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx #: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Signed document (adjective)" msgctxt "Signed document (adjective)"

View File

@@ -0,0 +1,26 @@
-- CreateIndex
CREATE INDEX "Envelope_type_idx" ON "Envelope"("type");
-- CreateIndex
CREATE INDEX "Envelope_status_idx" ON "Envelope"("status");
-- CreateIndex
CREATE INDEX "Envelope_createdAt_idx" ON "Envelope"("createdAt");
-- CreateIndex
CREATE INDEX "Organisation_name_idx" ON "Organisation"("name");
-- CreateIndex
CREATE INDEX "Organisation_ownerUserId_idx" ON "Organisation"("ownerUserId");
-- 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 "Team_name_idx" ON "Team"("name");

View File

@@ -430,9 +430,12 @@ model Envelope {
envelopeAttachments EnvelopeAttachment[] envelopeAttachments EnvelopeAttachment[]
@@index([folderId]) @@index([type])
@@index([teamId]) @@index([status])
@@index([userId]) @@index([userId])
@@index([teamId])
@@index([folderId])
@@index([createdAt])
} }
model EnvelopeItem { model EnvelopeItem {
@@ -583,8 +586,10 @@ model Recipient {
fields Field[] fields Field[]
signatures Signature[] signatures Signature[]
@@index([envelopeId])
@@index([token]) @@index([token])
@@index([email])
@@index([envelopeId])
@@index([signedAt])
} }
enum FieldType { enum FieldType {
@@ -694,6 +699,9 @@ model Organisation {
organisationAuthenticationPortalId String @unique organisationAuthenticationPortalId String @unique
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id]) organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
@@index([name])
@@index([ownerUserId])
} }
model OrganisationMember { model OrganisationMember {
@@ -710,6 +718,7 @@ model OrganisationMember {
organisationGroupMembers OrganisationGroupMember[] organisationGroupMembers OrganisationGroupMember[]
@@unique([userId, organisationId]) @@unique([userId, organisationId])
@@index([organisationId])
} }
model OrganisationMemberInvite { model OrganisationMemberInvite {
@@ -883,6 +892,7 @@ model Team {
teamGlobalSettingsId String @unique teamGlobalSettingsId String @unique
teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade) teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade)
@@index([name])
@@index([organisationId]) @@index([organisationId])
} }