Compare commits

..

2 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
c5b7522ea0 refactor: improve find envelopes endpoint implementation 2025-11-25 21:13:01 +00:00
Ephraim Atta-Duncan
4d29a66ba1 feat: add find envelopes endpoint 2025-11-25 14:18:35 +00:00
28 changed files with 15170 additions and 16430 deletions

1
.npmrc
View File

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

View File

@@ -17,5 +17,6 @@
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"prisma.pinToPrisma6": true
}

View File

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

View File

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

View File

@@ -1,10 +1,6 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
import type { DateRange } from '@documenso/lib/types/search-params';
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';
@@ -42,17 +38,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
const { insights, page, perPage, dateRange, view, organisationName, organisationId } = loaderData;
const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
return (
<div>
<div className="flex items-center justify-between">
<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 className="mt-8">
<OrganisationInsightsTable

View File

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

View File

@@ -162,13 +162,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
<SettingsHeader
title={t`Manage 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} />

View File

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

View File

@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
@@ -24,6 +24,7 @@ import type {
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
@@ -164,6 +165,9 @@ test.describe('API V2 Envelopes', () => {
positionY: 0,
width: 0,
height: 0,
fieldMeta: {
type: 'signature',
},
},
{
type: FieldType.SIGNATURE,
@@ -173,6 +177,9 @@ test.describe('API V2 Envelopes', () => {
positionY: 0,
width: 0,
height: 0,
fieldMeta: {
type: 'signature',
},
},
],
},
@@ -557,4 +564,198 @@ test.describe('API V2 Envelopes', () => {
userEmail: userA.email,
});
});
test.describe('Envelope find endpoint', () => {
const createEnvelope = async (
request: APIRequestContext,
token: string,
payload: TCreateEnvelopePayload,
) => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
test('should find envelopes with pagination', async ({ request }) => {
// Create 3 envelopes
await createEnvelope(request, tokenA, {
type: EnvelopeType.DOCUMENT,
title: 'Document 1',
});
await createEnvelope(request, tokenA, {
type: EnvelopeType.DOCUMENT,
title: 'Document 2',
});
await createEnvelope(request, tokenA, {
type: EnvelopeType.TEMPLATE,
title: 'Template 1',
});
// Find all envelopes
const res = await request.get(`${baseUrl}/envelope`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TFindEnvelopesResponse;
expect(response.data.length).toBe(3);
expect(response.count).toBe(3);
expect(response.currentPage).toBe(1);
expect(response.totalPages).toBe(1);
// Test pagination
const paginatedRes = await request.get(`${baseUrl}/envelope?perPage=2&page=1`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(paginatedRes.ok()).toBeTruthy();
const paginatedResponse = (await paginatedRes.json()) as TFindEnvelopesResponse;
expect(paginatedResponse.data.length).toBe(2);
expect(paginatedResponse.count).toBe(3);
expect(paginatedResponse.totalPages).toBe(2);
});
test('should filter envelopes by type', async ({ request }) => {
await createEnvelope(request, tokenA, {
type: EnvelopeType.DOCUMENT,
title: 'Document Only',
});
await createEnvelope(request, tokenA, {
type: EnvelopeType.TEMPLATE,
title: 'Template Only',
});
// Filter by DOCUMENT type
const documentRes = await request.get(`${baseUrl}/envelope?type=DOCUMENT`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(documentRes.ok()).toBeTruthy();
const documentResponse = (await documentRes.json()) as TFindEnvelopesResponse;
expect(documentResponse.data.every((e) => e.type === EnvelopeType.DOCUMENT)).toBe(true);
// Filter by TEMPLATE type
const templateRes = await request.get(`${baseUrl}/envelope?type=TEMPLATE`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TFindEnvelopesResponse;
expect(templateResponse.data.every((e) => e.type === EnvelopeType.TEMPLATE)).toBe(true);
});
test('should filter envelopes by status', async ({ request }) => {
await createEnvelope(request, tokenA, {
type: EnvelopeType.DOCUMENT,
title: 'Draft Document',
});
// Filter by DRAFT status (default for new envelopes)
const res = await request.get(`${baseUrl}/envelope?status=DRAFT`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TFindEnvelopesResponse;
expect(response.data.every((e) => e.status === DocumentStatus.DRAFT)).toBe(true);
});
test('should search envelopes by query', async ({ request }) => {
await createEnvelope(request, tokenA, {
type: EnvelopeType.DOCUMENT,
title: 'Unique Searchable Title',
});
await createEnvelope(request, tokenA, {
type: EnvelopeType.DOCUMENT,
title: 'Another Document',
});
const res = await request.get(`${baseUrl}/envelope?query=Unique%20Searchable`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TFindEnvelopesResponse;
expect(response.data.length).toBe(1);
expect(response.data[0].title).toBe('Unique Searchable Title');
});
test('should not return envelopes from other users', async ({ request }) => {
// Create envelope for userA
await createEnvelope(request, tokenA, {
type: EnvelopeType.DOCUMENT,
title: 'UserA Document',
});
// Create envelope for userB
await createEnvelope(request, tokenB, {
type: EnvelopeType.DOCUMENT,
title: 'UserB Document',
});
// userA should only see their own envelopes
const resA = await request.get(`${baseUrl}/envelope`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(resA.ok()).toBeTruthy();
const responseA = (await resA.json()) as TFindEnvelopesResponse;
expect(responseA.data.every((e) => e.title !== 'UserB Document')).toBe(true);
// userB should only see their own envelopes
const resB = await request.get(`${baseUrl}/envelope`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(resB.ok()).toBeTruthy();
const responseB = (await resB.json()) as TFindEnvelopesResponse;
expect(responseB.data.every((e) => e.title !== 'UserA Document')).toBe(true);
});
test('should return envelope with expected schema fields', async ({ request }) => {
await createEnvelope(request, tokenA, {
type: EnvelopeType.DOCUMENT,
title: 'Schema Test Document',
});
const res = await request.get(`${baseUrl}/envelope`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TFindEnvelopesResponse;
const envelope = response.data.find((e) => e.title === 'Schema Test Document');
expect(envelope).toBeDefined();
expect(envelope?.id).toBeDefined();
expect(envelope?.type).toBe(EnvelopeType.DOCUMENT);
expect(envelope?.status).toBe(DocumentStatus.DRAFT);
expect(envelope?.recipients).toBeDefined();
expect(envelope?.user).toBeDefined();
expect(envelope?.team).toBeDefined();
});
});
});

View File

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

View File

@@ -33,32 +33,25 @@ export async function getSigningVolume({
let findQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.where((eb) =>
eb.or([
eb('o.name', 'ilike', `%${search}%`),
eb.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
),
]),
.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)),
)
.select((eb) => [
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
'o.id as id',
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
eb
.selectFrom('Envelope as e')
.innerJoin('Team as t', 't.id', 'e.teamId')
.whereRef('t.organisationId', '=', 'o.id')
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.where('e.deletedAt', 'is', null)
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.select(sql<number>`count(e.id)`.as('count'))
.as('signingVolume'),
]);
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
])
.groupBy(['o.id', 'o.name', 'o.customerId']);
switch (sortBy) {
case 'name':
@@ -78,18 +71,11 @@ 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.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
),
]),
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
@@ -118,77 +104,64 @@ export async function getOrganisationInsights({
const offset = Math.max(page - 1, 0) * perPage;
const now = new Date();
let dateCondition = sql<boolean>`1=1`;
let dateCondition = sql`1=1`;
if (startDate && endDate) {
dateCondition = sql<boolean>`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<boolean>`e."createdAt" >= ${thirtyDaysAgo}`;
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
break;
}
case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateCondition = sql<boolean>`e."createdAt" >= ${ninetyDaysAgo}`;
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
break;
}
case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateCondition = sql<boolean>`e."createdAt" >= ${oneYearAgo}`;
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
break;
}
case 'allTime':
default:
dateCondition = sql<boolean>`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.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
),
]),
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select((eb) => [
.select([
'o.id as id',
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
'signingVolume',
),
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
'subscriptionStatus',
),
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.select(sql<number>`count(t.id)`.as('count'))
.as('teamCount'),
eb
.selectFrom('OrganisationMember as om')
.whereRef('om.organisationId', '=', 'o.id')
.select(sql<number>`count(om.id)`.as('count'))
.as('memberCount'),
eb
.selectFrom('Envelope as e')
.innerJoin('Team as t', 't.id', 'e.teamId')
.whereRef('t.organisationId', '=', 'o.id')
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.where('e.deletedAt', 'is', null)
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where(dateCondition)
.select(sql<number>`count(e.id)`.as('count'))
.as('signingVolume'),
]);
])
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
switch (sortBy) {
case 'name':
@@ -208,18 +181,11 @@ 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.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
),
]),
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);

View File

@@ -0,0 +1,202 @@
import type {
DocumentSource,
DocumentStatus,
Envelope,
EnvelopeType,
Prisma,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
import { getTeamById } from '../team/get-team';
export type FindEnvelopesOptions = {
userId: number;
teamId: number;
type?: EnvelopeType;
templateId?: number;
source?: DocumentSource;
status?: DocumentStatus;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Pick<Envelope, 'createdAt'>;
direction: 'asc' | 'desc';
};
query?: string;
folderId?: string;
};
export const findEnvelopes = async ({
userId,
teamId,
type,
templateId,
source,
status,
page = 1,
perPage = 10,
orderBy,
query = '',
folderId,
}: FindEnvelopesOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const team = await getTeamById({
userId,
teamId,
});
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const searchFilter: Prisma.EnvelopeWhereInput = query
? {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
],
}
: {};
const visibilityFilter: Prisma.EnvelopeWhereInput = {
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
const teamEmailFilters: Prisma.EnvelopeWhereInput[] = [];
if (team.teamEmail) {
teamEmailFilters.push(
{
user: {
email: team.teamEmail.email,
},
},
{
recipients: {
some: {
email: team.teamEmail.email,
},
},
},
);
}
const whereClause: Prisma.EnvelopeWhereInput = {
AND: [
searchFilter,
{
OR: [
{
teamId: team.id,
...visibilityFilter,
},
{
userId,
},
...teamEmailFilters,
],
},
{
deletedAt: null,
},
],
};
if (type) {
whereClause.type = type;
}
if (templateId) {
whereClause.templateId = templateId;
}
if (source) {
whereClause.source = source;
}
if (status) {
whereClause.status = status;
}
if (folderId !== undefined) {
whereClause.folderId = folderId;
} else {
whereClause.folderId = null;
}
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
team: {
select: {
id: true,
url: true,
},
},
},
}),
prisma.envelope.count({
where: whereClause,
}),
]);
const maskedData = data.map((envelope) =>
maskRecipientTokensForDocument({
document: envelope,
user,
}),
);
const mappedData = maskedData.map((envelope) => ({
...envelope,
recipients: envelope.Recipient,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
}));
return {
data: mappedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof mappedData>;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -115,5 +115,40 @@ export type TEnvelopeLite = z.infer<typeof ZEnvelopeLiteSchema>;
/**
* A version of the envelope response schema when returning multiple envelopes at once from a single API endpoint.
*/
// export const ZEnvelopeManySchema = X
// export type TEnvelopeMany = z.infer<typeof ZEnvelopeManySchema>;
export const ZEnvelopeManySchema = EnvelopeSchema.pick({
internalVersion: true,
type: true,
status: true,
source: true,
visibility: true,
templateType: true,
id: true,
secondaryId: true,
externalId: true,
createdAt: true,
updatedAt: true,
completedAt: true,
deletedAt: true,
title: true,
authOptions: true,
formValues: true,
publicTitle: true,
publicDescription: true,
userId: true,
teamId: true,
folderId: true,
templateId: true,
}).extend({
user: z.object({
id: z.number(),
name: z.string(),
email: z.string(),
}),
recipients: ZEnvelopeRecipientLiteSchema.array(),
team: TeamSchema.pick({
id: true,
url: true,
}).nullable(),
});
export type TEnvelopeMany = z.infer<typeof ZEnvelopeManySchema>;

View File

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

View File

@@ -0,0 +1,56 @@
import { findEnvelopes } from '@documenso/lib/server-only/envelope/find-envelopes';
import { authenticatedProcedure } from '../trpc';
import {
ZFindEnvelopesRequestSchema,
ZFindEnvelopesResponseSchema,
findEnvelopesMeta,
} from './find-envelopes.types';
export const findEnvelopesRoute = authenticatedProcedure
.meta(findEnvelopesMeta)
.input(ZFindEnvelopesRequestSchema)
.output(ZFindEnvelopesResponseSchema)
.query(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const {
query,
type,
templateId,
page,
perPage,
orderByDirection,
orderByColumn,
source,
status,
folderId,
} = input;
ctx.logger.info({
input: {
query,
type,
templateId,
source,
status,
folderId,
page,
perPage,
},
});
return await findEnvelopes({
userId: user.id,
teamId,
type,
templateId,
query,
source,
status,
page,
perPage,
folderId,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
});
});

View File

@@ -0,0 +1,46 @@
import { DocumentSource, DocumentStatus, EnvelopeType } from '@prisma/client';
import { z } from 'zod';
import { ZEnvelopeManySchema } from '@documenso/lib/types/envelope';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import type { TrpcRouteMeta } from '../trpc';
export const findEnvelopesMeta: TrpcRouteMeta = {
openapi: {
method: 'GET',
path: '/envelope',
summary: 'Find envelopes',
description: 'Find envelopes based on search criteria',
tags: ['Envelope'],
},
};
export const ZFindEnvelopesRequestSchema = ZFindSearchParamsSchema.extend({
type: z
.nativeEnum(EnvelopeType)
.describe('Filter envelopes by type (DOCUMENT or TEMPLATE).')
.optional(),
templateId: z
.number()
.describe('Filter envelopes by the template ID used to create it.')
.optional(),
source: z
.nativeEnum(DocumentSource)
.describe('Filter envelopes by how it was created.')
.optional(),
status: z
.nativeEnum(DocumentStatus)
.describe('Filter envelopes by the current status.')
.optional(),
folderId: z.string().describe('Filter envelopes by folder ID.').optional(),
orderByColumn: z.enum(['createdAt']).optional(),
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
});
export const ZFindEnvelopesResponseSchema = ZFindResultResponse.extend({
data: ZEnvelopeManySchema.array(),
});
export type TFindEnvelopesRequest = z.infer<typeof ZFindEnvelopesRequestSchema>;
export type TFindEnvelopesResponse = z.infer<typeof ZFindEnvelopesResponseSchema>;

View File

@@ -18,6 +18,7 @@ import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-enve
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
import { findEnvelopesRoute } from './find-envelopes';
import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
@@ -65,6 +66,7 @@ export const envelopeRouter = router({
set: setEnvelopeFieldsRoute,
sign: signEnvelopeFieldRoute,
},
find: findEnvelopesRoute,
get: getEnvelopeRoute,
create: createEnvelopeRoute,
use: useEnvelopeRoute,