mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: certificate qrcode (#1755)
Adds document access tokens and QR code functionality to enable secure document sharing via URLs. It includes a new document access page that allows viewing and downloading documents through tokenized links.
This commit is contained in:
@ -4,14 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Team } from '@prisma/client';
|
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||||
import { type Document, type Recipient, SigningStatus } from '@prisma/client';
|
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -43,9 +43,7 @@ import { StackAvatar } from '../general/stack-avatar';
|
|||||||
const FORM_ID = 'resend-email';
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
export type DocumentResendDialogProps = {
|
export type DocumentResendDialogProps = {
|
||||||
document: Document & {
|
document: TDocumentRow;
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { DocumentData } from '@prisma/client';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
|
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
||||||
|
|
||||||
|
export type DocumentCertificateQRViewProps = {
|
||||||
|
documentId: number;
|
||||||
|
title: string;
|
||||||
|
documentData: DocumentData;
|
||||||
|
password?: string | null;
|
||||||
|
recipientCount?: number;
|
||||||
|
completedDate?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentCertificateQRView = ({
|
||||||
|
documentId,
|
||||||
|
title,
|
||||||
|
documentData,
|
||||||
|
password,
|
||||||
|
recipientCount = 0,
|
||||||
|
completedDate,
|
||||||
|
}: DocumentCertificateQRViewProps) => {
|
||||||
|
const { data: documentUrl } = trpc.shareLink.getDocumentInternalUrlForQRCode.useQuery({
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentUrl);
|
||||||
|
|
||||||
|
const formattedDate = completedDate
|
||||||
|
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (documentUrl) {
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}
|
||||||
|
}, [documentUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-md">
|
||||||
|
{/* Dialog for internal document link */}
|
||||||
|
{documentUrl && (
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Document found in your account</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This document is available in your Documenso account. You can view more details,
|
||||||
|
recipients, and audit logs there.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<a href={documentUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Trans>Go to document</Trans>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-medium">{title}</h1>
|
||||||
|
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
|
||||||
|
<p>
|
||||||
|
<Trans>{recipientCount} recipients</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<Trans>Completed on {formattedDate}</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShareDocumentDownloadButton title={title} documentData={documentData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 w-full">
|
||||||
|
<PDFViewer key={documentData.id} documentData={documentData} password={password} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
|
||||||
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
|
import type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type ShareDocumentDownloadButtonProps = {
|
||||||
|
title: string;
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShareDocumentDownloadButton = ({
|
||||||
|
title,
|
||||||
|
documentData,
|
||||||
|
}: ShareDocumentDownloadButtonProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const onDownloadClick = async () => {
|
||||||
|
try {
|
||||||
|
setIsDownloading(true);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadPDF({ documentData, fileName: title });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button loading={isDownloading} onClick={onDownloadClick}>
|
||||||
|
{!isDownloading && <Download className="mr-2 h-4 w-4" />}
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@ -9,6 +8,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
@ -18,11 +18,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentsTableActionButtonProps = {
|
export type DocumentsTableActionButtonProps = {
|
||||||
row: Document & {
|
row: TDocumentRow;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@ -22,6 +21,7 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
@ -43,11 +43,7 @@ import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/d
|
|||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentsTableActionDropdownProps = {
|
export type DocumentsTableActionDropdownProps = {
|
||||||
row: Document & {
|
row: TDocumentRow;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
|
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
export type DataTableTitleProps = {
|
export type DataTableTitleProps = {
|
||||||
row: Document & {
|
row: TDocumentRow;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
team: Pick<Team, 'url'> | null;
|
|
||||||
recipients: Recipient[];
|
|
||||||
};
|
|
||||||
teamUrl?: string;
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import { DateTime } from 'luxon';
|
|||||||
import { redirect } from 'react-router';
|
import { redirect } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
import { renderSVG } from 'uqr';
|
||||||
|
|
||||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
RECIPIENT_ROLES_DESCRIPTION,
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
@ -342,7 +344,18 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isPlatformDocument && (
|
{isPlatformDocument && (
|
||||||
<div className="my-8 flex-row-reverse">
|
<div className="my-8 flex-row-reverse space-y-4">
|
||||||
|
<div className="flex items-end justify-end gap-x-4">
|
||||||
|
<div
|
||||||
|
className="flex h-24 w-24 justify-center"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderSVG(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${document.qrToken}`, {
|
||||||
|
ecc: 'Q',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end justify-end gap-x-4">
|
<div className="flex items-end justify-end gap-x-4">
|
||||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||||
{_(msg`Signing certificate provided by`)}:
|
{_(msg`Signing certificate provided by`)}:
|
||||||
|
|||||||
11
apps/remix/app/routes/_share+/_layout.tsx
Normal file
11
apps/remix/app/routes/_share+/_layout.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,6 +22,11 @@ const IMAGE_SIZE = {
|
|||||||
export const loader = async ({ params }: Route.LoaderArgs) => {
|
export const loader = async ({ params }: Route.LoaderArgs) => {
|
||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
|
|
||||||
|
// QR codes are not supported for OpenGraph images
|
||||||
|
if (slug.startsWith('qr_')) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const baseUrl = NEXT_PUBLIC_WEBAPP_URL();
|
const baseUrl = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
const [interSemiBold, interRegular, caveatRegular] = await Promise.all([
|
const [interSemiBold, interRegular, caveatRegular] = await Promise.all([
|
||||||
@ -1,10 +1,17 @@
|
|||||||
import { redirect } from 'react-router';
|
import { redirect, useLoaderData } from 'react-router';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { getDocumentByAccessToken } from '@documenso/lib/server-only/document/get-document-by-access-token';
|
||||||
|
|
||||||
|
import { DocumentCertificateQRView } from '~/components/general/document/document-certificate-qr-view';
|
||||||
|
|
||||||
import type { Route } from './+types/share.$slug';
|
import type { Route } from './+types/share.$slug';
|
||||||
|
|
||||||
export function meta({ params: { slug } }: Route.MetaArgs) {
|
export function meta({ params: { slug } }: Route.MetaArgs) {
|
||||||
|
if (slug.startsWith('qr_')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ title: 'Documenso - Share' },
|
{ title: 'Documenso - Share' },
|
||||||
{ description: 'I just signed a document in style with Documenso!' },
|
{ description: 'I just signed a document in style with Documenso!' },
|
||||||
@ -43,11 +50,23 @@ export function meta({ params: { slug } }: Route.MetaArgs) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loader = ({ request }: Route.LoaderArgs) => {
|
export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) => {
|
||||||
|
if (slug.startsWith('qr_')) {
|
||||||
|
const document = await getDocumentByAccessToken({ token: slug });
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const userAgent = request.headers.get('User-Agent') ?? '';
|
const userAgent = request.headers.get('User-Agent') ?? '';
|
||||||
|
|
||||||
if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) {
|
if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) {
|
||||||
return null;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is hardcoded because this whole meta is hardcoded anyway for Documenso.
|
// Is hardcoded because this whole meta is hardcoded anyway for Documenso.
|
||||||
@ -55,5 +74,20 @@ export const loader = ({ request }: Route.LoaderArgs) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SharePage() {
|
export default function SharePage() {
|
||||||
|
const { document } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
return (
|
||||||
|
<DocumentCertificateQRView
|
||||||
|
documentId={document.id}
|
||||||
|
title={document.title}
|
||||||
|
documentData={document.documentData}
|
||||||
|
password={document.documentMeta?.password}
|
||||||
|
recipientCount={document.recipients?.length ?? 0}
|
||||||
|
completedDate={document.completedAt ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <div></div>;
|
return <div></div>;
|
||||||
}
|
}
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
ZWebhookDocumentSchema,
|
ZWebhookDocumentSchema,
|
||||||
mapDocumentToWebhookDocumentPayload,
|
mapDocumentToWebhookDocumentPayload,
|
||||||
} from '../../../types/webhook-payload';
|
} from '../../../types/webhook-payload';
|
||||||
|
import { prefixedId } from '../../../universal/id';
|
||||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||||
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
|
||||||
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
||||||
@ -130,6 +131,17 @@ export const run = async ({
|
|||||||
documentData.data = documentData.initialData;
|
documentData.data = documentData.initialData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!document.qrToken) {
|
||||||
|
await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const pdfData = await getFileServerSide(documentData);
|
const pdfData = await getFileServerSide(documentData);
|
||||||
|
|
||||||
const certificateData =
|
const certificateData =
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|||||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
|
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
|
||||||
@ -142,6 +142,7 @@ export const createDocumentV2 = async ({
|
|||||||
const document = await tx.document.create({
|
const document = await tx.document.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
externalId: data.externalId,
|
externalId: data.externalId,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
ZWebhookDocumentSchema,
|
ZWebhookDocumentSchema,
|
||||||
mapDocumentToWebhookDocumentPayload,
|
mapDocumentToWebhookDocumentPayload,
|
||||||
} from '../../types/webhook-payload';
|
} from '../../types/webhook-payload';
|
||||||
|
import { prefixedId } from '../../universal/id';
|
||||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||||
@ -115,6 +116,7 @@ export const createDocument = async ({
|
|||||||
const document = await tx.document.create({
|
const document = await tx.document.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
externalId,
|
externalId,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { DocumentSource, type Prisma } from '@prisma/client';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { prefixedId } from '../../universal/id';
|
||||||
import { getDocumentWhereInput } from './get-document-by-id';
|
import { getDocumentWhereInput } from './get-document-by-id';
|
||||||
|
|
||||||
export interface DuplicateDocumentOptions {
|
export interface DuplicateDocumentOptions {
|
||||||
@ -56,6 +57,7 @@ export const duplicateDocument = async ({
|
|||||||
const createDocumentArguments: Prisma.DocumentCreateArgs = {
|
const createDocumentArguments: Prisma.DocumentCreateArgs = {
|
||||||
data: {
|
data: {
|
||||||
title: document.title,
|
title: document.title,
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
user: {
|
user: {
|
||||||
connect: {
|
connect: {
|
||||||
id: document.userId,
|
id: document.userId,
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetDocumentByAccessTokenOptions = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Missing token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
qrToken: token,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
completedAt: true,
|
||||||
|
documentData: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
data: true,
|
||||||
|
initialData: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentMeta: {
|
||||||
|
select: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recipients: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@ -19,7 +19,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { mailer } from '@documenso/email/mailer';
|
import { mailer } from '@documenso/email/mailer';
|
||||||
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
|
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
|
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
|
||||||
|
|
||||||
@ -276,6 +276,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
// Create the document and non direct template recipients.
|
// Create the document and non direct template recipients.
|
||||||
const document = await tx.document.create({
|
const document = await tx.document.create({
|
||||||
data: {
|
data: {
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
source: DocumentSource.TEMPLATE_DIRECT_LINK,
|
source: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
userId: template.userId,
|
userId: template.userId,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { DocumentSource, type RecipientRole } from '@prisma/client';
|
import { DocumentSource, type RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
export type CreateDocumentFromTemplateLegacyOptions = {
|
export type CreateDocumentFromTemplateLegacyOptions = {
|
||||||
@ -70,6 +70,7 @@ export const createDocumentFromTemplateLegacy = async ({
|
|||||||
|
|
||||||
const document = await prisma.document.create({
|
const document = await prisma.document.create({
|
||||||
data: {
|
data: {
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
source: DocumentSource.TEMPLATE,
|
source: DocumentSource.TEMPLATE,
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||||
@ -372,6 +372,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
return await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
const document = await tx.document.create({
|
const document = await tx.document.create({
|
||||||
data: {
|
data: {
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
source: DocumentSource.TEMPLATE,
|
source: DocumentSource.TEMPLATE,
|
||||||
externalId: externalId || template.externalId,
|
externalId: externalId || template.externalId,
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
|
|||||||
@ -86,6 +86,8 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
|
|||||||
useLegacyFieldInsertion: true,
|
useLegacyFieldInsertion: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A version of the document response schema when returning multiple documents at once from a single API endpoint.
|
* A version of the document response schema when returning multiple documents at once from a single API endpoint.
|
||||||
*/
|
*/
|
||||||
@ -119,3 +121,5 @@ export const ZDocumentManySchema = DocumentSchema.pick({
|
|||||||
url: true,
|
url: true,
|
||||||
}).nullable(),
|
}).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type TDocumentMany = z.infer<typeof ZDocumentManySchema>;
|
||||||
|
|||||||
@ -3,3 +3,9 @@ import { customAlphabet } from 'nanoid';
|
|||||||
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
|
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
|
||||||
|
|
||||||
export { nanoid } from 'nanoid';
|
export { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export const fancyId = customAlphabet('abcdefhiklmnorstuvwxyz', 16);
|
||||||
|
|
||||||
|
export const prefixedId = (prefix: string, length = 16) => {
|
||||||
|
return `${prefix}_${fancyId(length)}`;
|
||||||
|
};
|
||||||
|
|||||||
@ -31,4 +31,38 @@ export const kyselyPrisma = remember('kyselyPrisma', () =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const prismaWithLogging = remember('prismaWithLogging', () => {
|
||||||
|
const client = new PrismaClient({
|
||||||
|
datasourceUrl: getDatabaseUrl(),
|
||||||
|
log: [
|
||||||
|
{
|
||||||
|
emit: 'event',
|
||||||
|
level: 'query',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
client.$on('query', (e) => {
|
||||||
|
console.log('query:', e.query);
|
||||||
|
console.log('params:', e.params);
|
||||||
|
console.log('duration:', e.duration);
|
||||||
|
|
||||||
|
const params = JSON.parse(e.params) as unknown[];
|
||||||
|
|
||||||
|
const query = e.query.replace(/\$\d+/g, (match) => {
|
||||||
|
const index = Number(match.replace('$', ''));
|
||||||
|
|
||||||
|
if (index > params.length) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(params[index - 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('formatted query:', query);
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
});
|
||||||
|
|
||||||
export { sql } from 'kysely';
|
export { sql } from 'kysely';
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" ADD COLUMN "qrToken" TEXT;
|
||||||
@ -315,6 +315,7 @@ enum DocumentVisibility {
|
|||||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
|
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
|
||||||
model Document {
|
model Document {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
|
||||||
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
|
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
|
||||||
userId Int /// @zod.number.describe("The ID of the user that created this document.")
|
userId Int /// @zod.number.describe("The ID of the user that created this document.")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { procedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZGetDocumentInternalUrlForQRCodeInput,
|
||||||
|
ZGetDocumentInternalUrlForQRCodeOutput,
|
||||||
|
} from './get-document-internal-url-for-qr-code.types';
|
||||||
|
|
||||||
|
export const getDocumentInternalUrlForQRCodeRoute = procedure
|
||||||
|
.input(ZGetDocumentInternalUrlForQRCodeInput)
|
||||||
|
.output(ZGetDocumentInternalUrlForQRCodeOutput)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const { documentId } = input;
|
||||||
|
|
||||||
|
if (!ctx.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
id: documentId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: documentId,
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team: {
|
||||||
|
where: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.team) {
|
||||||
|
return `${NEXT_PUBLIC_WEBAPP_URL()}/t/${document.team.url}/documents/${document.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${NEXT_PUBLIC_WEBAPP_URL()}/documents/${document.id}`;
|
||||||
|
});
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZGetDocumentInternalUrlForQRCodeInput = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGetDocumentInternalUrlForQRCodeInput = z.infer<
|
||||||
|
typeof ZGetDocumentInternalUrlForQRCodeInput
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZGetDocumentInternalUrlForQRCodeOutput = z.string().nullable();
|
||||||
|
|
||||||
|
export type TGetDocumentInternalUrlForQRCodeOutput = z.infer<
|
||||||
|
typeof ZGetDocumentInternalUrlForQRCodeOutput
|
||||||
|
>;
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { createOrGetShareLink } from '@documenso/lib/server-only/share/create-or-get-share-link';
|
import { createOrGetShareLink } from '@documenso/lib/server-only/share/create-or-get-share-link';
|
||||||
|
|
||||||
import { procedure, router } from '../trpc';
|
import { procedure, router } from '../trpc';
|
||||||
|
import { getDocumentInternalUrlForQRCodeRoute } from './get-document-internal-url-for-qr-code';
|
||||||
import { ZCreateOrGetShareLinkMutationSchema } from './schema';
|
import { ZCreateOrGetShareLinkMutationSchema } from './schema';
|
||||||
|
|
||||||
export const shareLinkRouter = router({
|
export const shareLinkRouter = router({
|
||||||
@ -21,4 +22,6 @@ export const shareLinkRouter = router({
|
|||||||
|
|
||||||
return await createOrGetShareLink({ documentId, userId: ctx.user.id });
|
return await createOrGetShareLink({ documentId, userId: ctx.user.id });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getDocumentInternalUrlForQRCode: getDocumentInternalUrlForQRCodeRoute,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user