mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge branch 'main' into feat/auto-placing-fields
This commit is contained in:
@ -32,6 +32,7 @@ export const getDocumentWithDetailsById = async ({
|
||||
return {
|
||||
...envelope,
|
||||
envelopeId: envelope.id,
|
||||
internalVersion: envelope.internalVersion,
|
||||
documentData: {
|
||||
...firstDocumentData,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
folderId,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
|
||||
ownerCountsWhereInput = {
|
||||
...ownerCountsWhereInput,
|
||||
...visibilityFiltersWhereInput,
|
||||
...searchFilter,
|
||||
AND: [
|
||||
...(Array.isArray(visibilityFiltersWhereInput.AND)
|
||||
? visibilityFiltersWhereInput.AND
|
||||
: visibilityFiltersWhereInput.AND
|
||||
? [visibilityFiltersWhereInput.AND]
|
||||
: []),
|
||||
searchFilter,
|
||||
rootPageFilter,
|
||||
folderId ? { folderId } : {},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail) {
|
||||
@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
};
|
||||
|
||||
notSignedCountsGroupByArgs = {
|
||||
@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
|
||||
@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
@ -13,9 +14,13 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@ -24,6 +29,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@ -56,6 +62,7 @@ export const sendDocument = async ({
|
||||
recipients: {
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
},
|
||||
fields: true,
|
||||
documentMeta: true,
|
||||
envelopeItems: {
|
||||
select: {
|
||||
@ -165,6 +172,78 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
|
||||
|
||||
// Auto insert radio and checkboxes that have default values.
|
||||
if (envelope.internalVersion === 2) {
|
||||
for (const field of envelope.fields) {
|
||||
if (field.type === FieldType.RADIO) {
|
||||
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const checkedItemIndex = values.findIndex((value) => value.checked);
|
||||
|
||||
if (checkedItemIndex !== -1) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: toRadioCustomText(checkedItemIndex),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DROPDOWN) {
|
||||
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
if (defaultValue && values.some((value) => value.value === defaultValue)) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: defaultValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.CHECKBOX) {
|
||||
const {
|
||||
values = [],
|
||||
validationRule,
|
||||
validationLength,
|
||||
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const checkedIndices: number[] = [];
|
||||
|
||||
values.forEach((value, i) => {
|
||||
if (value.checked) {
|
||||
checkedIndices.push(i);
|
||||
}
|
||||
});
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (validationRule && validationLength) {
|
||||
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
|
||||
|
||||
if (!validation) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid checkbox validation rule',
|
||||
});
|
||||
}
|
||||
|
||||
isValid = validateCheckboxLength(
|
||||
checkedIndices.length,
|
||||
validation.value,
|
||||
validationLength,
|
||||
);
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: toCheckboxCustomText(checkedIndices),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
@ -177,6 +256,37 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.internalVersion === 2) {
|
||||
const autoInsertedFields = await Promise.all(
|
||||
fieldsToAutoInsert.map(async (field) => {
|
||||
return await tx.field.update({
|
||||
where: {
|
||||
id: field.fieldId,
|
||||
},
|
||||
data: {
|
||||
customText: field.customText,
|
||||
inserted: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
fields: autoInsertedFields.map((field) => ({
|
||||
fieldId: field.id,
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
})),
|
||||
},
|
||||
// Don't put metadata or user here since it's a system event.
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type CreateAttachmentOptions = {
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
data: {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const createAttachment = async ({
|
||||
envelopeId,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
}: CreateAttachmentOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.COMPLETED || envelope.status === DocumentStatus.REJECTED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId,
|
||||
type: 'link',
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteAttachmentOptions = {
|
||||
id: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentOptions) => {
|
||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Attachment not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||
attachment.envelope.status === DocumentStatus.REJECTED
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.envelopeAttachment.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type FindAttachmentsByEnvelopeIdOptions = {
|
||||
envelopeId: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const findAttachmentsByEnvelopeId = async ({
|
||||
envelopeId,
|
||||
userId,
|
||||
teamId,
|
||||
}: FindAttachmentsByEnvelopeIdOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type FindAttachmentsByTokenOptions = {
|
||||
envelopeId: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const findAttachmentsByToken = async ({
|
||||
envelopeId,
|
||||
token,
|
||||
}: FindAttachmentsByTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type FindAttachmentsByTeamOptions = {
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const findAttachmentsByTeam = async ({
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: FindAttachmentsByTeamOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type UpdateAttachmentOptions = {
|
||||
id: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
data: { label?: string; data?: string };
|
||||
};
|
||||
|
||||
export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttachmentOptions) => {
|
||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Attachment not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||
attachment.envelope.status === DocumentStatus.REJECTED
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
};
|
||||
@ -20,6 +20,7 @@ import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-rou
|
||||
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@ -58,6 +59,11 @@ export type CreateEnvelopeOptions = {
|
||||
recipients?: TCreateEnvelopeRequest['recipients'];
|
||||
folderId?: string;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
data: string;
|
||||
type?: TEnvelopeAttachmentType;
|
||||
}>;
|
||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -67,6 +73,7 @@ export const createEnvelope = async ({
|
||||
teamId,
|
||||
normalizePdf,
|
||||
data,
|
||||
attachments,
|
||||
meta,
|
||||
requestMetadata,
|
||||
internalVersion,
|
||||
@ -246,6 +253,15 @@ export const createEnvelope = async ({
|
||||
})),
|
||||
},
|
||||
},
|
||||
envelopeAttachments: {
|
||||
createMany: {
|
||||
data: (attachments || []).map((attachment) => ({
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
type: attachment.type ?? 'link',
|
||||
})),
|
||||
},
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
authOptions,
|
||||
@ -338,6 +354,7 @@ export const createEnvelope = async ({
|
||||
fields: true,
|
||||
folder: true,
|
||||
envelopeItems: true,
|
||||
envelopeAttachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,144 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||
|
||||
export type GetRecipientEnvelopeByTokenOptions = {
|
||||
token: string;
|
||||
userId?: number;
|
||||
accessAuth?: TDocumentAuthMethods;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all the values and details for a direct template envelope that a recipient requires.
|
||||
*
|
||||
* Do not overexpose any information that the recipient should not have.
|
||||
*/
|
||||
export const getEnvelopeForDirectTemplateSigning = async ({
|
||||
token,
|
||||
userId,
|
||||
accessAuth,
|
||||
}: GetRecipientEnvelopeByTokenOptions): Promise<EnvelopeForSigningResponse> => {
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Missing token',
|
||||
});
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
status: DocumentStatus.DRAFT,
|
||||
directLink: {
|
||||
enabled: true,
|
||||
token,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
include: {
|
||||
fields: {
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
directLink: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = (envelope?.recipients || []).find(
|
||||
(r) => r.id === envelope?.directLink?.directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!envelope || !recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope has no items',
|
||||
});
|
||||
}
|
||||
|
||||
const documentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: envelope.authOptions,
|
||||
recipient,
|
||||
userId,
|
||||
authOptions: accessAuth,
|
||||
});
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid access values',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
||||
|
||||
const sender = settings.includeSenderDetails
|
||||
? {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
}
|
||||
: {
|
||||
email: envelope.team.teamEmail?.email || '',
|
||||
name: envelope.team.name || '',
|
||||
};
|
||||
|
||||
return ZEnvelopeForSigningResponse.parse({
|
||||
envelope,
|
||||
recipient: {
|
||||
...recipient,
|
||||
token: envelope.directLink?.token || '',
|
||||
},
|
||||
recipientSignature: null,
|
||||
isRecipientsTurn: true,
|
||||
isCompleted: false,
|
||||
isRejected: false,
|
||||
sender,
|
||||
settings: {
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
brandingEnabled: settings.brandingEnabled,
|
||||
brandingLogo: settings.brandingLogo,
|
||||
},
|
||||
} satisfies EnvelopeForSigningResponse);
|
||||
};
|
||||
@ -23,7 +23,7 @@ export type GetRecipientEnvelopeByTokenOptions = {
|
||||
accessAuth?: TDocumentAuthMethods;
|
||||
};
|
||||
|
||||
const ZEnvelopeForSigningResponse = z.object({
|
||||
export const ZEnvelopeForSigningResponse = z.object({
|
||||
envelope: EnvelopeSchema.pick({
|
||||
type: true,
|
||||
status: true,
|
||||
@ -31,6 +31,7 @@ const ZEnvelopeForSigningResponse = z.object({
|
||||
secondaryId: true,
|
||||
internalVersion: true,
|
||||
completedAt: true,
|
||||
updatedAt: true,
|
||||
deletedAt: true,
|
||||
title: true,
|
||||
authOptions: true,
|
||||
|
||||
@ -54,3 +54,54 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
|
||||
recipientHasAccount: Boolean(recipientUserAccount),
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
directLink: {
|
||||
enabled: true,
|
||||
token,
|
||||
},
|
||||
status: DocumentStatus.DRAFT,
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
directLink: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = envelope.recipients.find(
|
||||
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipientUserAccount = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: recipient.email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
recipientEmail: recipient.email,
|
||||
recipientHasAccount: Boolean(recipientUserAccount),
|
||||
} as const;
|
||||
};
|
||||
|
||||
@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
|
||||
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const errors = validateNumberField(
|
||||
String(numberFieldParsedMeta.value),
|
||||
numberFieldParsedMeta,
|
||||
false,
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
@ -304,7 +306,10 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedField;
|
||||
return {
|
||||
...upsertedField,
|
||||
formId: field.formId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -338,17 +343,25 @@ export const setFieldsForDocument = async ({
|
||||
}
|
||||
|
||||
// Filter out fields that have been removed or have been updated.
|
||||
const filteredFields = existingFields.filter((field) => {
|
||||
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||
const mappedFilteredFields = existingFields
|
||||
.filter((field) => {
|
||||
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
return !isRemoved && !isUpdated;
|
||||
})
|
||||
.map((field) => ({
|
||||
...mapFieldToLegacyField(field, envelope),
|
||||
formId: undefined,
|
||||
}));
|
||||
|
||||
const mappedPersistentFields = persistedFields.map((field) => ({
|
||||
...mapFieldToLegacyField(field, envelope),
|
||||
formId: field?.formId,
|
||||
}));
|
||||
|
||||
return {
|
||||
fields: [...filteredFields, ...persistedFields].map((field) =>
|
||||
mapFieldToLegacyField(field, envelope),
|
||||
),
|
||||
fields: [...mappedFilteredFields, ...mappedPersistentFields],
|
||||
};
|
||||
};
|
||||
|
||||
@ -357,6 +370,7 @@ export const setFieldsForDocument = async ({
|
||||
*/
|
||||
type FieldData = {
|
||||
id?: number | null;
|
||||
formId?: string;
|
||||
envelopeItemId: string;
|
||||
type: FieldType;
|
||||
recipientId: number;
|
||||
|
||||
@ -27,6 +27,7 @@ export type SetFieldsForTemplateOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
fields: {
|
||||
id?: number | null;
|
||||
formId?: string;
|
||||
envelopeItemId: string;
|
||||
type: FieldType;
|
||||
recipientId: number;
|
||||
@ -111,10 +112,10 @@ export const setFieldsForTemplate = async ({
|
||||
};
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(
|
||||
const persistedFields = await Promise.all(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedFields.map((field) => {
|
||||
linkedFields.map(async (field) => {
|
||||
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
|
||||
|
||||
if (field.type === FieldType.TEXT && field.fieldMeta) {
|
||||
@ -176,7 +177,7 @@ export const setFieldsForTemplate = async ({
|
||||
}
|
||||
|
||||
// Proceed with upsert operation
|
||||
return prisma.field.upsert({
|
||||
const upsertedField = await prisma.field.upsert({
|
||||
where: {
|
||||
id: field._persisted?.id ?? -1,
|
||||
envelopeId: envelope.id,
|
||||
@ -219,6 +220,11 @@ export const setFieldsForTemplate = async ({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...upsertedField,
|
||||
formId: field.formId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@ -240,9 +246,17 @@ export const setFieldsForTemplate = async ({
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
|
||||
const mappedFilteredFields = filteredFields.map((field) => ({
|
||||
...mapFieldToLegacyField(field, envelope),
|
||||
formId: undefined,
|
||||
}));
|
||||
|
||||
const mappedPersistentFields = persistedFields.map((field) => ({
|
||||
...mapFieldToLegacyField(field, envelope),
|
||||
formId: field?.formId,
|
||||
}));
|
||||
|
||||
return {
|
||||
fields: [...filteredFields, ...persistedFields].map((field) =>
|
||||
mapFieldToLegacyField(field, envelope),
|
||||
),
|
||||
fields: [...mappedFilteredFields, ...mappedPersistentFields],
|
||||
};
|
||||
};
|
||||
|
||||
@ -205,6 +205,7 @@ export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalCla
|
||||
flags: {
|
||||
...subscriptionClaim.flags,
|
||||
},
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
};
|
||||
|
||||
@ -1,133 +1,64 @@
|
||||
import type { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { renderField } from '../../universal/field-renderer/render-field';
|
||||
import { getPageSize } from './get-page-size';
|
||||
|
||||
// const font = await pdf.embedFont(
|
||||
// isSignatureField ? fontCaveat : fontNoto,
|
||||
// isSignatureField ? { features: { calt: false } } : undefined,
|
||||
// );
|
||||
// const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
|
||||
// const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
type InsertFieldInPDFV2Options = {
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
fields: FieldWithSignature[];
|
||||
};
|
||||
|
||||
export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
export const insertFieldInPDFV2 = async ({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fields,
|
||||
}: InsertFieldInPDFV2Options) => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
FontLibrary.use([
|
||||
path.join(fontPath, 'caveat.ttf'),
|
||||
path.join(fontPath, 'noto-sans.ttf'),
|
||||
path.join(fontPath, 'noto-sans-japanese.ttf'),
|
||||
path.join(fontPath, 'noto-sans-chinese.ttf'),
|
||||
path.join(fontPath, 'noto-sans-korean.ttf'),
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
const pages = pdf.getPages();
|
||||
|
||||
const page = pages.at(field.page - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Page ${field.page} does not exist`);
|
||||
}
|
||||
|
||||
const pageRotation = page.getRotation();
|
||||
|
||||
let pageRotationInDegrees = match(pageRotation.type)
|
||||
.with(RotationTypes.Degrees, () => pageRotation.angle)
|
||||
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
|
||||
.exhaustive();
|
||||
|
||||
// Round to the closest multiple of 90 degrees.
|
||||
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
|
||||
|
||||
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
|
||||
|
||||
// Todo: Evenloeps - getPageSize this had extra logic? Ask lucas
|
||||
|
||||
console.log({
|
||||
cropBox: page.getCropBox(),
|
||||
mediaBox: page.getMediaBox(),
|
||||
mediaBox2: page.getSize(),
|
||||
});
|
||||
|
||||
const { width: pageWidth, height: pageHeight } = getPageSize(page);
|
||||
|
||||
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
|
||||
// However when we load the PDF in the backend, the rotation is applied.
|
||||
//
|
||||
// To account for this, we swap the width and height for pages that are rotated by 90/270
|
||||
// degrees. This is so we can calculate the virtual position the field was placed if it
|
||||
// was correctly oriented in the frontend.
|
||||
//
|
||||
// Then when we insert the fields, we apply a transformation to the position of the field
|
||||
// so it is rotated correctly.
|
||||
if (isPageRotatedToLandscape) {
|
||||
// [pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
|
||||
console.log({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fieldWidth: field.width,
|
||||
fieldHeight: field.height,
|
||||
});
|
||||
|
||||
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
|
||||
const layer = new Konva.Layer();
|
||||
|
||||
// Will render onto the layer.
|
||||
renderField({
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
},
|
||||
pageLayer: layer,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
mode: 'export',
|
||||
});
|
||||
const insertedFields = fields.filter((field) => field.inserted);
|
||||
|
||||
// Render the fields onto the layer.
|
||||
for (const field of insertedFields) {
|
||||
renderField({
|
||||
scale: 1,
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
},
|
||||
translations: null,
|
||||
pageLayer: layer,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
mode: 'export',
|
||||
});
|
||||
}
|
||||
|
||||
stage.add(layer);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const canvas = layer.canvas._canvas as unknown as Canvas;
|
||||
|
||||
const renderedField = await canvas.toBuffer('svg');
|
||||
|
||||
fs.writeFileSync(
|
||||
`rendered-field-${field.envelopeId}--${field.id}.svg`,
|
||||
renderedField.toString('utf-8'),
|
||||
);
|
||||
|
||||
// Embed the SVG into the PDF
|
||||
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
|
||||
|
||||
// Calculate position to cover the whole page
|
||||
// pdf-lib coordinates: (0,0) is bottom-left, y increases upward
|
||||
const svgWidth = pageWidth; // Use full page width
|
||||
const svgHeight = pageHeight; // Use full page height
|
||||
|
||||
const x = 0; // Start from left edge
|
||||
const y = pageHeight; // Start from bottom edge
|
||||
|
||||
// Draw the SVG on the page
|
||||
page.drawSvg(svgElement, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: svgWidth,
|
||||
height: svgHeight,
|
||||
});
|
||||
|
||||
return pdf;
|
||||
return await canvas.toBuffer('pdf');
|
||||
};
|
||||
|
||||
@ -162,12 +162,6 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
||||
}
|
||||
|
||||
if (user && user.email !== directRecipientEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Email must match if you are logged in',
|
||||
});
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
|
||||
extractDocumentAuthMethods({
|
||||
documentAuth: directTemplateEnvelope.authOptions,
|
||||
@ -340,7 +334,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: incrementedDocumentId.formattedDocumentId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
internalVersion: 1,
|
||||
internalVersion: directTemplateEnvelope.internalVersion,
|
||||
qrToken: prefixedId('qr'),
|
||||
source: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
templateId: directTemplateEnvelopeLegacyId,
|
||||
@ -640,6 +634,23 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
data: auditLogsToCreate,
|
||||
});
|
||||
|
||||
const templateAttachments = await tx.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId: directTemplateEnvelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (templateAttachments.length > 0) {
|
||||
await tx.envelopeAttachment.createMany({
|
||||
data: templateAttachments.map((attachment) => ({
|
||||
envelopeId: createdEnvelope.id,
|
||||
type: attachment.type,
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Send email to template owner.
|
||||
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
||||
recipientName: directRecipientEmail,
|
||||
|
||||
@ -91,6 +91,12 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
envelopeItemId?: string;
|
||||
}[];
|
||||
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
data: string;
|
||||
type?: 'link';
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Values that will override the predefined values in the template.
|
||||
*/
|
||||
@ -203,6 +209,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
|
||||
type: 'radio',
|
||||
label: field.label,
|
||||
values: newValues,
|
||||
direction: radioMeta.direction ?? 'vertical',
|
||||
};
|
||||
|
||||
return meta;
|
||||
@ -295,6 +302,7 @@ export const createDocumentFromTemplate = async ({
|
||||
requestMetadata,
|
||||
folderId,
|
||||
prefillFields,
|
||||
attachments,
|
||||
}: CreateDocumentFromTemplateOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
@ -388,8 +396,6 @@ export const createDocumentFromTemplate = async ({
|
||||
};
|
||||
});
|
||||
|
||||
const firstEnvelopeItemId = template.envelopeItems[0].id;
|
||||
|
||||
// Key = original envelope item ID
|
||||
// Value = duplicated envelope item ID.
|
||||
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
||||
@ -400,10 +406,14 @@ export const createDocumentFromTemplate = async ({
|
||||
template.envelopeItems.map(async (item, i) => {
|
||||
let documentDataIdToDuplicate = item.documentDataId;
|
||||
|
||||
const foundCustomDocumentData = customDocumentData.find(
|
||||
(customDocumentDataItem) =>
|
||||
customDocumentDataItem.envelopeItemId || firstEnvelopeItemId === item.id,
|
||||
);
|
||||
const foundCustomDocumentData = customDocumentData.find((customDocumentDataItem) => {
|
||||
// Handle empty envelopeItemId for backwards compatibility reasons.
|
||||
if (customDocumentDataItem.documentDataId && !customDocumentDataItem.envelopeItemId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return customDocumentDataItem.envelopeItemId === item.id;
|
||||
});
|
||||
|
||||
if (foundCustomDocumentData) {
|
||||
documentDataIdToDuplicate = foundCustomDocumentData.documentDataId;
|
||||
@ -667,6 +677,33 @@ export const createDocumentFromTemplate = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
const templateAttachments = await tx.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
const attachmentsToCreate = [
|
||||
...templateAttachments.map((attachment) => ({
|
||||
envelopeId: envelope.id,
|
||||
type: attachment.type,
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
...(attachments || []).map((attachment) => ({
|
||||
envelopeId: envelope.id,
|
||||
type: attachment.type || 'link',
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
];
|
||||
|
||||
if (attachmentsToCreate.length > 0) {
|
||||
await tx.envelopeAttachment.createMany({
|
||||
data: attachmentsToCreate,
|
||||
});
|
||||
}
|
||||
|
||||
const createdEnvelope = await tx.envelope.findFirst({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@ -38,7 +38,6 @@ export const getTemplateByDirectLinkToken = async ({
|
||||
|
||||
const directLink = envelope?.directLink;
|
||||
|
||||
// Todo: Envelopes
|
||||
const firstDocumentData = envelope?.envelopeItems[0]?.documentData;
|
||||
|
||||
// Doing this to enforce type safety for directLink.
|
||||
|
||||
Reference in New Issue
Block a user