mirror of
https://github.com/documenso/documenso.git
synced 2025-11-26 22:44:41 +10:00
Compare commits
3 Commits
feat/find-
...
chore/tran
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c6e873de2 | ||
|
|
94646cd48a | ||
|
|
14db9b8203 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -17,6 +17,5 @@
|
|||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
}
|
||||||
"prisma.pinToPrisma6": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import type { Team, User } from '@prisma/client';
|
import type { Team, User } from '@prisma/client';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -24,7 +24,6 @@ import type {
|
|||||||
TCreateEnvelopeResponse,
|
TCreateEnvelopeResponse,
|
||||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
} 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 { 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 { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||||
|
|
||||||
@@ -165,9 +164,6 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
positionY: 0,
|
positionY: 0,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
fieldMeta: {
|
|
||||||
type: 'signature',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.SIGNATURE,
|
type: FieldType.SIGNATURE,
|
||||||
@@ -177,9 +173,6 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
positionY: 0,
|
positionY: 0,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
fieldMeta: {
|
|
||||||
type: 'signature',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -564,198 +557,4 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
userEmail: userA.email,
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(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))
|
||||||
.select([
|
.select([
|
||||||
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
|
sql<number>`count(e.id)`.as('totalDocuments'),
|
||||||
'totalTeams',
|
sql<number>`count(case when e.status in ('DRAFT', 'PENDING') then 1 end)`.as(
|
||||||
|
'activeDocuments',
|
||||||
),
|
),
|
||||||
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
|
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('completedDocuments'),
|
||||||
'totalMembers',
|
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('volumeAllTime'),
|
||||||
),
|
|
||||||
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
|
(createdAtFrom
|
||||||
? sql<number>`(
|
? sql<number>`count(case when e.status = 'COMPLETED' and e."createdAt" >= ${createdAtFrom} then 1 end)`
|
||||||
SELECT COUNT(DISTINCT e2.id)
|
: sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`
|
||||||
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'),
|
).as('volumeThisPeriod'),
|
||||||
sql<number>`(
|
])
|
||||||
SELECT COUNT(DISTINCT e2.id)
|
.executeTakeFirst();
|
||||||
FROM "Envelope" AS e2
|
|
||||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
const [teamCount, memberCount, envelopeStats] = await Promise.all([
|
||||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
teamCountQuery,
|
||||||
)`.as('volumeAllTime'),
|
memberCountQuery,
|
||||||
|
envelopeStatsQuery,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await summaryQuery.executeTakeFirst();
|
|
||||||
|
|
||||||
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()]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
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>;
|
|
||||||
};
|
|
||||||
@@ -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)"
|
||||||
|
|||||||
@@ -115,40 +115,5 @@ 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.
|
* A version of the envelope response schema when returning multiple envelopes at once from a single API endpoint.
|
||||||
*/
|
*/
|
||||||
export const ZEnvelopeManySchema = EnvelopeSchema.pick({
|
// export const ZEnvelopeManySchema = X
|
||||||
internalVersion: true,
|
// export type TEnvelopeMany = z.infer<typeof ZEnvelopeManySchema>;
|
||||||
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>;
|
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -18,7 +18,6 @@ import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-enve
|
|||||||
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
||||||
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
|
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
|
||||||
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
|
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
|
||||||
import { findEnvelopesRoute } from './find-envelopes';
|
|
||||||
import { getEnvelopeRoute } from './get-envelope';
|
import { getEnvelopeRoute } from './get-envelope';
|
||||||
import { getEnvelopeItemsRoute } from './get-envelope-items';
|
import { getEnvelopeItemsRoute } from './get-envelope-items';
|
||||||
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
|
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
|
||||||
@@ -66,7 +65,6 @@ export const envelopeRouter = router({
|
|||||||
set: setEnvelopeFieldsRoute,
|
set: setEnvelopeFieldsRoute,
|
||||||
sign: signEnvelopeFieldRoute,
|
sign: signEnvelopeFieldRoute,
|
||||||
},
|
},
|
||||||
find: findEnvelopesRoute,
|
|
||||||
get: getEnvelopeRoute,
|
get: getEnvelopeRoute,
|
||||||
create: createEnvelopeRoute,
|
create: createEnvelopeRoute,
|
||||||
use: useEnvelopeRoute,
|
use: useEnvelopeRoute,
|
||||||
|
|||||||
Reference in New Issue
Block a user