mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: add seal-document sweep job and admin unsealed documents page (#2563)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
BarChart3,
|
||||
Building2Icon,
|
||||
FileStack,
|
||||
@@ -123,6 +124,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/unsealed-documents') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/unsealed-documents">
|
||||
<AlertTriangleIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Unsealed Documents</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon, Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export default function AdminUnsealedDocumentsPage() {
|
||||
const { _, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
|
||||
const {
|
||||
data: findUnsealedData,
|
||||
isPending: isLoading,
|
||||
refetch,
|
||||
} = trpc.admin.document.findUnsealed.useQuery(
|
||||
{
|
||||
page: page || 1,
|
||||
perPage: perPage || 20,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: resealDocument, isPending: isResealing } =
|
||||
trpc.admin.document.reseal.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({ title: _(msg`Seal job triggered`), variant: 'default' });
|
||||
void refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: _(msg`Failed to trigger seal`), variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const results = findUnsealedData ?? {
|
||||
data: [],
|
||||
perPage: 20,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: _(msg`Title`),
|
||||
accessorKey: 'title',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
to={`/admin/documents/${row.original.id}`}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[15rem]"
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Owner`),
|
||||
accessorKey: 'ownerEmail',
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div>{row.original.ownerName}</div>
|
||||
<div>{row.original.ownerEmail}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Last Signed`),
|
||||
accessorKey: 'lastSignedAt',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.lastSignedAt) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return i18n.date(row.original.lastSignedAt, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Stuck For`),
|
||||
accessorKey: 'stuckDuration',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.lastSignedAt) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
const signedAt = DateTime.fromJSDate(new Date(row.original.lastSignedAt));
|
||||
const stuckMs = Date.now() - signedAt.toMillis();
|
||||
const isOld = stuckMs > SIX_HOURS_MS;
|
||||
const label = signedAt.toRelative() ?? '';
|
||||
|
||||
return <Badge variant={isOld ? 'destructive' : 'warning'}>{label}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
accessorKey: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isResealing}
|
||||
onClick={() => void resealDocument({ id: row.original.id })}
|
||||
>
|
||||
<Trans>Reseal</Trans>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, [isResealing]);
|
||||
|
||||
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
||||
updateSearchParams({
|
||||
page: newPage,
|
||||
perPage: newPerPage,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangleIcon className="h-8 w-8 text-destructive" />
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Unsealed Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Documents where all recipients have signed but the document has not been sealed. Documents
|
||||
stuck for more than 6 hours are no longer retried by the sweep job.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="relative mt-8">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage ?? 20}
|
||||
currentPage={results.currentPage ?? 1}
|
||||
totalPages={results.totalPages ?? 1}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
"dev:remix": "turbo run dev --filter=@documenso/remix",
|
||||
"dev": "npm run translate:compile && turbo run dev --filter=@documenso/remix",
|
||||
"dev:remix": "npm run translate:compile && turbo run dev --filter=@documenso/remix",
|
||||
"dev:docs": "turbo run dev --filter=@documenso/documentation",
|
||||
"dev:openpage-api": "turbo run dev --filter=@documenso/openpage-api",
|
||||
"start": "turbo run start --filter=@documenso/remix --filter=@documenso/documentation --filter=@documenso/openpage-api",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-w
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
|
||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
||||
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,7 @@ export const jobsClient = new JobClient([
|
||||
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
|
||||
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
|
||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
|
||||
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
import { mapSecondaryIdToDocumentId } from '../../../utils/envelope';
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSealDocumentSweepJobDefinition } from './seal-document-sweep';
|
||||
|
||||
export const run = async ({ io }: { payload: TSealDocumentSweepJobDefinition; io: JobRunIO }) => {
|
||||
const now = DateTime.now();
|
||||
const fifteenMinutesAgo = now.minus({ minutes: 15 }).toJSDate();
|
||||
const sixHoursAgo = now.minus({ hours: 6 }).toJSDate();
|
||||
|
||||
// Find all PENDING envelopes that should have been sealed but weren't.
|
||||
//
|
||||
// A document is ready to seal when either:
|
||||
// 1. All recipients are SIGNED or have role CC (normal completion)
|
||||
// 2. Any recipient has REJECTED (rejection triggers immediate seal)
|
||||
//
|
||||
// We only look at documents where the last action was between 15 minutes
|
||||
// and 6 hours ago. The lower bound avoids racing with the normal seal-document
|
||||
// job that fires on completion. The upper bound stops us from endlessly retrying
|
||||
// documents that are stuck due to a deeper issue (e.g. corrupt PDF).
|
||||
const unsealedEnvelopes = await kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope')
|
||||
.select(['Envelope.id', 'Envelope.secondaryId'])
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.where('Envelope.deletedAt', 'is', null)
|
||||
// Ensure there is at least one recipient.
|
||||
.where((eb) =>
|
||||
eb.exists(eb.selectFrom('Recipient').whereRef('Recipient.envelopeId', '=', 'Envelope.id')),
|
||||
)
|
||||
// Document is ready to seal: all recipients are SIGNED/CC, or any recipient REJECTED.
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
// Case 1: All recipients are either SIGNED or CC.
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.signingStatus', '!=', sql.lit(SigningStatus.SIGNED))
|
||||
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
),
|
||||
),
|
||||
// Case 2: Any recipient has rejected.
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
]),
|
||||
)
|
||||
// Exclude envelopes where a recipient signed/rejected within the last 15 minutes
|
||||
// to avoid racing with the standard completion flow.
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.signedAt', '>', fifteenMinutesAgo),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Exclude envelopes where all activity is older than 6 hours.
|
||||
// These are likely stuck due to a deeper issue and should not be retried.
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.signedAt', '>', sixHoursAgo),
|
||||
),
|
||||
)
|
||||
.limit(100)
|
||||
.execute();
|
||||
|
||||
if (unsealedEnvelopes.length === 0) {
|
||||
io.logger.info('No unsealed documents found');
|
||||
return;
|
||||
}
|
||||
|
||||
io.logger.info(`Found ${unsealedEnvelopes.length} unsealed documents`);
|
||||
|
||||
await Promise.allSettled(
|
||||
unsealedEnvelopes.map(async (envelope) => {
|
||||
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
io.logger.info(`Triggering seal for document ${documentId} (${envelope.id})`);
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId,
|
||||
isResealing: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID = 'internal.seal-document-sweep';
|
||||
|
||||
const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
|
||||
|
||||
export type TSealDocumentSweepJobDefinition = z.infer<
|
||||
typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION = {
|
||||
id: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
|
||||
name: 'Seal Document Sweep',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
|
||||
schema: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA,
|
||||
cron: '*/15 * * * *', // Every 15 minutes.
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./seal-document-sweep.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
|
||||
TSealDocumentSweepJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
|
||||
export type AdminUnsealedDocument = {
|
||||
id: string;
|
||||
secondaryId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
ownerName: string | null;
|
||||
ownerEmail: string;
|
||||
lastSignedAt: Date | null;
|
||||
};
|
||||
|
||||
export type AdminFindUnsealedDocumentsOptions = {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
|
||||
export const adminFindUnsealedDocuments = async ({
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
}: AdminFindUnsealedDocumentsOptions): Promise<FindResultResponse<AdminUnsealedDocument[]>> => {
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const baseQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope')
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.where('Envelope.deletedAt', 'is', null)
|
||||
// Must have at least one recipient.
|
||||
.where((eb) =>
|
||||
eb.exists(eb.selectFrom('Recipient').whereRef('Recipient.envelopeId', '=', 'Envelope.id')),
|
||||
)
|
||||
// Document is ready to seal: all recipients are SIGNED/CC, or any recipient REJECTED.
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
// Case 1: All recipients are either SIGNED or CC.
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.signingStatus', '!=', sql.lit(SigningStatus.SIGNED))
|
||||
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
),
|
||||
),
|
||||
// Case 2: Any recipient has rejected.
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
const [data, countResult] = await Promise.all([
|
||||
baseQuery
|
||||
.innerJoin('User', 'User.id', 'Envelope.userId')
|
||||
.select([
|
||||
'Envelope.id',
|
||||
'Envelope.secondaryId',
|
||||
'Envelope.title',
|
||||
'Envelope.status',
|
||||
'Envelope.createdAt',
|
||||
'Envelope.updatedAt',
|
||||
'Envelope.userId',
|
||||
'Envelope.teamId',
|
||||
'User.name as ownerName',
|
||||
'User.email as ownerEmail',
|
||||
])
|
||||
.select((eb) =>
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.select(sql<Date>`max("Recipient"."signedAt")`.as('lastSignedAt'))
|
||||
.as('lastSignedAt'),
|
||||
)
|
||||
.orderBy('Envelope.createdAt', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset)
|
||||
.execute(),
|
||||
baseQuery.select(({ fn }) => [fn.countAll().as('count')]).execute(),
|
||||
]);
|
||||
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
data: data as unknown as AdminUnsealedDocument[],
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { adminFindUnsealedDocuments } from '@documenso/lib/server-only/admin/admin-find-unsealed-documents';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import {
|
||||
ZFindUnsealedDocumentsRequestSchema,
|
||||
ZFindUnsealedDocumentsResponseSchema,
|
||||
} from './find-unsealed-documents.types';
|
||||
|
||||
export const findUnsealedDocumentsRoute = adminProcedure
|
||||
.input(ZFindUnsealedDocumentsRequestSchema)
|
||||
.output(ZFindUnsealedDocumentsResponseSchema)
|
||||
.query(async ({ input }) => {
|
||||
const { page, perPage } = input;
|
||||
|
||||
return await adminFindUnsealedDocuments({ page, perPage });
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
|
||||
export const ZFindUnsealedDocumentsRequestSchema = ZFindSearchParamsSchema.pick({
|
||||
page: true,
|
||||
perPage: true,
|
||||
}).extend({
|
||||
perPage: z.number().optional().default(20),
|
||||
});
|
||||
|
||||
export const ZAdminUnsealedDocumentSchema = z.object({
|
||||
id: z.string(),
|
||||
secondaryId: z.string(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
userId: z.number(),
|
||||
teamId: z.number(),
|
||||
ownerName: z.string().nullable(),
|
||||
ownerEmail: z.string(),
|
||||
lastSignedAt: z.date().nullable(),
|
||||
});
|
||||
|
||||
export const ZFindUnsealedDocumentsResponseSchema = ZFindResultResponse.extend({
|
||||
data: ZAdminUnsealedDocumentSchema.array(),
|
||||
});
|
||||
|
||||
export type TFindUnsealedDocumentsRequest = z.infer<typeof ZFindUnsealedDocumentsRequestSchema>;
|
||||
export type TFindUnsealedDocumentsResponse = z.infer<typeof ZFindUnsealedDocumentsResponseSchema>;
|
||||
@@ -13,6 +13,7 @@ import { findDocumentJobsRoute } from './find-document-jobs';
|
||||
import { findDocumentsRoute } from './find-documents';
|
||||
import { findEmailDomainsRoute } from './find-email-domains';
|
||||
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
||||
import { findUnsealedDocumentsRoute } from './find-unsealed-documents';
|
||||
import { findUserTeamsRoute } from './find-user-teams';
|
||||
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
||||
import { getEmailDomainRoute } from './get-email-domain';
|
||||
@@ -65,6 +66,7 @@ export const adminRouter = router({
|
||||
},
|
||||
document: {
|
||||
find: findDocumentsRoute,
|
||||
findUnsealed: findUnsealedDocumentsRoute,
|
||||
delete: deleteDocumentRoute,
|
||||
reseal: resealDocumentRoute,
|
||||
findJobs: findDocumentJobsRoute,
|
||||
|
||||
Reference in New Issue
Block a user