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:
Ephraim Duncan
2025-04-28 01:30:09 +00:00
committed by GitHub
parent 6a41a37bd4
commit bdb0b0ea88
26 changed files with 423 additions and 31 deletions

View File

@ -22,6 +22,7 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { prefixedId } from '../../../universal/id';
import { getFileServerSide } from '../../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
@ -130,6 +131,17 @@ export const run = async ({
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 certificateData =

View File

@ -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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
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 { prisma } from '@documenso/prisma';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
@ -142,6 +142,7 @@ export const createDocumentV2 = async ({
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId: data.externalId,
documentDataId,
userId,

View File

@ -13,6 +13,7 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { determineDocumentVisibility } from '../../utils/document-visibility';
@ -115,6 +116,7 @@ export const createDocument = async ({
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId,
documentDataId,
userId,

View File

@ -3,6 +3,7 @@ import { DocumentSource, type Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { prefixedId } from '../../universal/id';
import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentOptions {
@ -56,6 +57,7 @@ export const duplicateDocument = async ({
const createDocumentArguments: Prisma.DocumentCreateArgs = {
data: {
title: document.title,
qrToken: prefixedId('qr'),
user: {
connect: {
id: document.userId,

View File

@ -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;
};

View File

@ -19,7 +19,7 @@ import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
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 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.
const document = await tx.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: template.id,
userId: template.userId,

View File

@ -1,6 +1,6 @@
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';
export type CreateDocumentFromTemplateLegacyOptions = {
@ -70,6 +70,7 @@ export const createDocumentFromTemplateLegacy = async ({
const document = await prisma.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
templateId: template.id,
userId,

View File

@ -11,7 +11,7 @@ import {
} from '@prisma/client';
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 type { SupportedLanguageCodes } from '../../constants/i18n';
@ -372,6 +372,7 @@ export const createDocumentFromTemplate = async ({
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId,
templateId: template.id,

View File

@ -86,6 +86,8 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
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.
*/
@ -119,3 +121,5 @@ export const ZDocumentManySchema = DocumentSchema.pick({
url: true,
}).nullable(),
});
export type TDocumentMany = z.infer<typeof ZDocumentManySchema>;

View File

@ -3,3 +3,9 @@ import { customAlphabet } from 'nanoid';
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
export { nanoid } from 'nanoid';
export const fancyId = customAlphabet('abcdefhiklmnorstuvwxyz', 16);
export const prefixedId = (prefix: string, length = 16) => {
return `${prefix}_${fancyId(length)}`;
};

View File

@ -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';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "qrToken" TEXT;

View File

@ -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';"])
model Document {
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.")
userId Int /// @zod.number.describe("The ID of the user that created this document.")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@ -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}`;
});

View File

@ -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
>;

View File

@ -1,6 +1,7 @@
import { createOrGetShareLink } from '@documenso/lib/server-only/share/create-or-get-share-link';
import { procedure, router } from '../trpc';
import { getDocumentInternalUrlForQRCodeRoute } from './get-document-internal-url-for-qr-code';
import { ZCreateOrGetShareLinkMutationSchema } from './schema';
export const shareLinkRouter = router({
@ -21,4 +22,6 @@ export const shareLinkRouter = router({
return await createOrGetShareLink({ documentId, userId: ctx.user.id });
}),
getDocumentInternalUrlForQRCode: getDocumentInternalUrlForQRCodeRoute,
});