mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
chore: fix merge conflicts
This commit is contained in:
@@ -286,7 +286,7 @@ const detectFieldsFromPage = async ({
|
||||
});
|
||||
|
||||
const result = await generateObject({
|
||||
model: vertex('gemini-3-pro-preview'),
|
||||
model: vertex('gemini-3-flash-preview'),
|
||||
system: SYSTEM_PROMPT,
|
||||
schema: ZSubmitDetectedFieldsInputSchema,
|
||||
messages,
|
||||
|
||||
@@ -207,7 +207,7 @@ const detectRecipientsFromImages = async ({
|
||||
});
|
||||
|
||||
const result = await generateObject({
|
||||
model: vertex('gemini-2.5-flash'),
|
||||
model: vertex('gemini-3-flash-preview'),
|
||||
system: SYSTEM_PROMPT,
|
||||
schema: ZDetectedRecipientsSchema,
|
||||
messages,
|
||||
|
||||
@@ -9,7 +9,10 @@ globalThis.Image = Image;
|
||||
|
||||
class SkiaCanvasFactory {
|
||||
_createCanvas(width: number, height: number) {
|
||||
return new Canvas(width, height);
|
||||
const canvas = new Canvas(width, height);
|
||||
canvas.gpu = false;
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
create(width: number, height: number) {
|
||||
@@ -44,10 +47,12 @@ export type PdfToImagesOptions = {
|
||||
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
|
||||
const { scale = 2 } = options;
|
||||
|
||||
const pdf = await pdfjsLib.getDocument({
|
||||
const task = await pdfjsLib.getDocument({
|
||||
data: pdfBytes,
|
||||
CanvasFactory: SkiaCanvasFactory,
|
||||
}).promise;
|
||||
});
|
||||
|
||||
const pdf = await task.promise;
|
||||
|
||||
const images = await pMap(
|
||||
Array.from({ length: pdf.numPages }),
|
||||
@@ -58,6 +63,8 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = new Canvas(viewport.width, viewport.height);
|
||||
canvas.gpu = false;
|
||||
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
|
||||
await page.render({
|
||||
@@ -68,18 +75,23 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
const result = {
|
||||
pageNumber,
|
||||
image: await canvas.toBuffer('jpeg'),
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
mimeType: 'image/jpeg',
|
||||
};
|
||||
|
||||
void page.cleanup();
|
||||
|
||||
return result;
|
||||
},
|
||||
{ concurrency: 10 },
|
||||
);
|
||||
|
||||
void pdf.destroy();
|
||||
void pdf.destroy().catch((e) => console.error(e));
|
||||
void task.destroy().catch((e) => console.error(e));
|
||||
|
||||
return images;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export const getDocumentCertificateAuditLogs = async ({
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -37,6 +38,9 @@ export const getDocumentCertificateAuditLogs = async ({
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
),
|
||||
|
||||
@@ -81,6 +81,7 @@ export type CreateEnvelopeOptions = {
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
recipients?: CreateEnvelopeRecipientOptions[];
|
||||
folderId?: string;
|
||||
delegatedDocumentOwner?: string;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
@@ -114,6 +115,7 @@ export const createEnvelope = async ({
|
||||
publicTitle,
|
||||
publicDescription,
|
||||
visibility: visibilityOverride,
|
||||
delegatedDocumentOwner,
|
||||
} = data;
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
@@ -256,6 +258,43 @@ export const createEnvelope = async ({
|
||||
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
||||
|
||||
const getValidatedDelegatedOwner = async () => {
|
||||
if (
|
||||
!settings.delegateDocumentOwnership ||
|
||||
!delegatedDocumentOwner ||
|
||||
requestMetadata.source === 'app'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delegatedOwner = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: delegatedDocumentOwner,
|
||||
},
|
||||
});
|
||||
|
||||
if (!delegatedOwner) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Delegated document owner must be a member of the team',
|
||||
});
|
||||
}
|
||||
|
||||
const isTeamMember = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId: delegatedOwner.id }),
|
||||
});
|
||||
|
||||
if (!isTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Delegated document owner must be a member of the team',
|
||||
});
|
||||
}
|
||||
|
||||
return delegatedOwner;
|
||||
};
|
||||
|
||||
const delegatedOwner = await getValidatedDelegatedOwner();
|
||||
const envelopeOwnerId = delegatedOwner?.id ?? userId;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const envelope = await tx.envelope.create({
|
||||
data: {
|
||||
@@ -285,7 +324,7 @@ export const createEnvelope = async ({
|
||||
})),
|
||||
},
|
||||
},
|
||||
userId,
|
||||
userId: envelopeOwnerId,
|
||||
teamId,
|
||||
authOptions,
|
||||
visibility,
|
||||
@@ -393,6 +432,9 @@ export const createEnvelope = async ({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
id: envelopeOwnerId,
|
||||
},
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
title,
|
||||
@@ -403,6 +445,25 @@ export const createEnvelope = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
// Create audit log for delegated owner if validation passed
|
||||
if (delegatedOwner) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
delegatedOwnerName: delegatedOwner.name,
|
||||
delegatedOwnerEmail: delegatedOwner.email,
|
||||
teamName: team.name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
Envelope,
|
||||
EnvelopeType,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type FindEnvelopesOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
type?: EnvelopeType;
|
||||
templateId?: number;
|
||||
source?: DocumentSource;
|
||||
status?: DocumentStatus;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Pick<Envelope, 'createdAt'>;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const findEnvelopes = async ({
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
templateId,
|
||||
source,
|
||||
status,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
query = '',
|
||||
folderId,
|
||||
}: FindEnvelopesOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await getTeamById({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = query
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const visibilityFilter: Prisma.EnvelopeWhereInput = {
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
};
|
||||
|
||||
const teamEmailFilters: Prisma.EnvelopeWhereInput[] = [];
|
||||
|
||||
if (team.teamEmail) {
|
||||
teamEmailFilters.push(
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause: Prisma.EnvelopeWhereInput = {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
...visibilityFilter,
|
||||
},
|
||||
{
|
||||
userId,
|
||||
},
|
||||
...teamEmailFilters,
|
||||
],
|
||||
},
|
||||
{
|
||||
folderId: folderId ?? null,
|
||||
deletedAt: null,
|
||||
},
|
||||
searchFilter,
|
||||
],
|
||||
};
|
||||
|
||||
if (type) {
|
||||
whereClause.type = type;
|
||||
}
|
||||
|
||||
if (templateId) {
|
||||
whereClause.templateId = templateId;
|
||||
}
|
||||
|
||||
if (source) {
|
||||
whereClause.source = source;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.envelope.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const maskedData = data.map((envelope) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document: envelope,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
|
||||
const mappedData = maskedData.map((envelope) => ({
|
||||
...envelope,
|
||||
recipients: envelope.Recipient,
|
||||
user: {
|
||||
id: envelope.user.id,
|
||||
name: envelope.user.name || '',
|
||||
email: envelope.user.email,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
data: mappedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof mappedData>;
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { EnvelopeType, Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdsOptions } from '../../utils/envelope';
|
||||
import { unsafeBuildEnvelopeIdsQuery } from '../../utils/envelope';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetEnvelopesByIdsOptions = {
|
||||
/**
|
||||
* The envelope IDs to fetch with their type.
|
||||
*/
|
||||
ids: EnvelopeIdsOptions;
|
||||
|
||||
/**
|
||||
* The user ID who has been authenticated.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The unvalidated team ID from the request.
|
||||
*/
|
||||
teamId: number;
|
||||
|
||||
/**
|
||||
* The type of envelope to get.
|
||||
*
|
||||
* Set to null to bypass check.
|
||||
*/
|
||||
type: EnvelopeType | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches multiple envelopes by their IDs with proper access control.
|
||||
*
|
||||
* Only returns envelopes that the user has valid access to based on:
|
||||
* 1. Document ownership (userId matches)
|
||||
* 2. Team membership with appropriate visibility level
|
||||
* 3. Team email ownership
|
||||
*
|
||||
* NOTE: Be extremely careful when modifying this function. Needs at minimum two reviewers to approve any changes.
|
||||
*/
|
||||
export const getEnvelopesByIds = async ({
|
||||
ids,
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
}: GetEnvelopesByIdsOptions) => {
|
||||
const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({
|
||||
ids,
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
});
|
||||
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
folder: true,
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
},
|
||||
fields: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
directLink: {
|
||||
select: {
|
||||
directTemplateRecipientId: true,
|
||||
enabled: true,
|
||||
id: true,
|
||||
token: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return envelopes.map((envelope) => ({
|
||||
...envelope,
|
||||
user: {
|
||||
id: envelope.user.id,
|
||||
name: envelope.user.name || '',
|
||||
email: envelope.user.email,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export type GetEnvelopesByIdsResponse = Awaited<ReturnType<typeof getEnvelopesByIds>>;
|
||||
|
||||
export type GetMultipleEnvelopeWhereInputOptions = {
|
||||
/**
|
||||
* The envelope IDs to fetch with their type.
|
||||
*/
|
||||
ids: EnvelopeIdsOptions;
|
||||
|
||||
/**
|
||||
* The user ID who has been authenticated.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The unknown teamId from the request.
|
||||
*/
|
||||
teamId: number;
|
||||
|
||||
/**
|
||||
* The type of envelope to get.
|
||||
*
|
||||
* Set to null to bypass check.
|
||||
*/
|
||||
type: EnvelopeType | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the where input for a multiple envelope Prisma query.
|
||||
*
|
||||
* This will return a query that allows a user to get documents if they have valid access to them.
|
||||
*
|
||||
* NOTE: Be extremely careful when modifying this function. Needs at minimum two reviewers to approve any changes.
|
||||
*/
|
||||
export const getMultipleEnvelopeWhereInput = async ({
|
||||
ids,
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
}: GetMultipleEnvelopeWhereInputOptions) => {
|
||||
// Backup validation incase something goes wrong.
|
||||
if (!ids.ids || !userId || !teamId || type === undefined) {
|
||||
console.error(`[CRTICAL ERROR]: MUST NEVER HAPPEN`);
|
||||
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope IDs not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate that the user belongs to the team provided.
|
||||
const team = await getTeamById({ teamId, userId });
|
||||
|
||||
const envelopeOrInput: Prisma.EnvelopeWhereInput[] = [
|
||||
// Allow access if they own the document.
|
||||
{
|
||||
userId,
|
||||
},
|
||||
// Or, if they belong to the team that the document is associated with.
|
||||
{
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
teamId: team.id,
|
||||
},
|
||||
];
|
||||
|
||||
// Allow access to documents sent from the team email.
|
||||
if (team.teamEmail) {
|
||||
envelopeOrInput.push({
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
||||
// NOTE: DO NOT PUT ANY CODE AFTER THIS POINT.
|
||||
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
||||
|
||||
const envelopeWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
...unsafeBuildEnvelopeIdsQuery(ids, type),
|
||||
OR: envelopeOrInput,
|
||||
};
|
||||
|
||||
// Final backup validation incase something goes wrong.
|
||||
if (
|
||||
!envelopeWhereInput.OR ||
|
||||
envelopeWhereInput.OR.length < 2 ||
|
||||
!userId ||
|
||||
!teamId ||
|
||||
!team.id ||
|
||||
teamId !== team.id
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Query not valid',
|
||||
});
|
||||
}
|
||||
|
||||
// Do not modify this return directly, all adjustments need to be made prior to the above if statement.
|
||||
return {
|
||||
envelopeWhereInput,
|
||||
team,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { deletedAccountServiceAccount } from '../user/service-accounts/deleted-account';
|
||||
|
||||
export type OrphanEnvelopesOptions = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const orphanEnvelopes = async ({ teamId }: OrphanEnvelopesOptions) => {
|
||||
const serviceAccount = await deletedAccountServiceAccount();
|
||||
|
||||
// Transfer all inflight and completed envelopes to the service account.
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
userId: serviceAccount.id,
|
||||
teamId: serviceAccount.ownedOrganisations[0].teams[0].id,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Transfer any remaining deleted envelopes to the service account.
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
userId: serviceAccount.id,
|
||||
teamId: serviceAccount.ownedOrganisations[0].teams[0].id,
|
||||
},
|
||||
});
|
||||
|
||||
// Then delete anything remaining across documents and templates.
|
||||
await prisma.envelope.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type TransferTeamEnvelopesOptions = {
|
||||
sourceTeamId: number;
|
||||
targetTeamId: number;
|
||||
};
|
||||
|
||||
export const transferTeamEnvelopes = async ({
|
||||
sourceTeamId,
|
||||
targetTeamId,
|
||||
}: TransferTeamEnvelopesOptions) => {
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
teamId: sourceTeamId,
|
||||
},
|
||||
data: {
|
||||
teamId: targetTeamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* !: This is a workaround to fix the memory leak in the skia-canvas library.
|
||||
* !: Internals are ported from the original `konva/skia-backend.js` file.
|
||||
*/
|
||||
import { Konva } from 'konva/lib/_CoreInternals';
|
||||
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
|
||||
|
||||
// @ts-expect-error skia-canvas satisfies the requirements
|
||||
global.DOMMatrix = DOMMatrix;
|
||||
|
||||
// @ts-expect-error skia-canvas satisfies the requirements
|
||||
global.Path2D = Path2D;
|
||||
Path2D.prototype.toString = () => '[object Path2D]';
|
||||
|
||||
Konva.Util['createCanvasElement'] = () => {
|
||||
const node = new Canvas(300, 300);
|
||||
node.gpu = false;
|
||||
|
||||
if (!('style' in node) || !node['style']) {
|
||||
Object.assign(node, { style: {} });
|
||||
}
|
||||
|
||||
node.toString = () => '[object HTMLCanvasElement]';
|
||||
const ctx = node.getContext('2d');
|
||||
|
||||
Object.defineProperty(ctx, 'canvas', {
|
||||
get: () => node,
|
||||
});
|
||||
|
||||
return node as unknown as HTMLCanvasElement;
|
||||
};
|
||||
|
||||
Konva.Util.createImageElement = () => {
|
||||
const node = new Image();
|
||||
node.toString = () => '[object HTMLImageElement]';
|
||||
|
||||
return node as unknown as HTMLImageElement;
|
||||
};
|
||||
|
||||
Konva._renderBackend = 'skia-canvas';
|
||||
|
||||
export default Konva;
|
||||
@@ -40,7 +40,10 @@ export const acceptOrganisationInvitation = async ({
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: organisationMemberInvite.email,
|
||||
email: {
|
||||
equals: organisationMemberInvite.email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
PDFDict,
|
||||
type PDFDocument,
|
||||
PDFName,
|
||||
PDFNumber,
|
||||
PDFRadioGroup,
|
||||
PDFRef,
|
||||
PDFStream,
|
||||
drawObject,
|
||||
popGraphicsState,
|
||||
pushGraphicsState,
|
||||
@@ -103,6 +105,36 @@ const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation)
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures that an appearance stream has the required dictionary entries to be
|
||||
* used as a Form XObject. Some PDFs have appearance streams that are missing
|
||||
* the /Subtype /Form entry, which causes Adobe Reader to fail to render them.
|
||||
*
|
||||
* Per PDF spec, a Form XObject stream requires:
|
||||
* - /Subtype /Form (required)
|
||||
* - /BBox (required, but should already exist for appearance streams)
|
||||
* - /FormType 1 (optional, defaults to 1)
|
||||
*/
|
||||
const normalizeAppearanceStream = (document: PDFDocument, appearanceRef: PDFRef) => {
|
||||
const appearanceStream = document.context.lookup(appearanceRef);
|
||||
|
||||
if (!(appearanceStream instanceof PDFStream)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dict = appearanceStream.dict;
|
||||
|
||||
// Ensure /Subtype /Form is set (required for XObject Form)
|
||||
if (!dict.has(PDFName.of('Subtype'))) {
|
||||
dict.set(PDFName.of('Subtype'), PDFName.of('Form'));
|
||||
}
|
||||
|
||||
// Ensure /FormType is set (optional, but good practice)
|
||||
if (!dict.has(PDFName.of('FormType'))) {
|
||||
dict.set(PDFName.of('FormType'), PDFNumber.of(1));
|
||||
}
|
||||
};
|
||||
|
||||
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
|
||||
try {
|
||||
const page = getPageForWidget(document, widget);
|
||||
@@ -117,6 +149,9 @@ const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidget
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the appearance stream has required XObject Form dictionary entries
|
||||
normalizeAppearanceStream(document, appearanceRef);
|
||||
|
||||
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
|
||||
|
||||
const rectangle = widget.getRectangle();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// sort-imports-ignore
|
||||
import 'konva/skia-backend';
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import Konva from 'konva';
|
||||
import path from 'node:path';
|
||||
@@ -23,6 +23,7 @@ export const insertFieldInPDFV2 = async ({
|
||||
}: InsertFieldInPDFV2Options) => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
|
||||
@@ -31,8 +32,8 @@ export const insertFieldInPDFV2 = async ({
|
||||
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
|
||||
});
|
||||
|
||||
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
|
||||
const layer = new Konva.Layer();
|
||||
let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight });
|
||||
let layer: Konva.Layer | null = new Konva.Layer();
|
||||
|
||||
// Render the fields onto the layer.
|
||||
for (const field of fields) {
|
||||
@@ -60,5 +61,13 @@ export const insertFieldInPDFV2 = async ({
|
||||
const canvas = layer.canvas._canvas as unknown as Canvas;
|
||||
|
||||
// Embed the SVG into the PDF
|
||||
return await canvas.toBuffer('pdf');
|
||||
const pdf = await canvas.toBuffer('pdf');
|
||||
|
||||
stage.destroy();
|
||||
layer.destroy();
|
||||
|
||||
stage = null;
|
||||
layer = null;
|
||||
|
||||
return pdf;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
@@ -38,11 +39,17 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
});
|
||||
|
||||
if (!apiToken) {
|
||||
throw new Error('Invalid token');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (apiToken.expires && apiToken.expires < new Date()) {
|
||||
throw new Error('Expired token');
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, {
|
||||
message: 'Expired token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle a silly choice from many moons ago
|
||||
@@ -54,7 +61,10 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
|
||||
// This will never happen but we need to narrow types
|
||||
if (!user) {
|
||||
throw new Error('Invalid token');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
export const getUserByApiToken = async ({ token }: { token: string }) => {
|
||||
@@ -19,14 +20,20 @@ export const getUserByApiToken = async ({ token }: { token: string }) => {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid token');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken);
|
||||
|
||||
// This should be impossible but we need to satisfy TypeScript
|
||||
if (!retrievedToken) {
|
||||
throw new Error('Invalid token');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
|
||||
|
||||
@@ -185,7 +185,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
documentAuth: directTemplateEnvelope.authOptions,
|
||||
});
|
||||
|
||||
const directRecipientName = user?.name || initialDirectRecipientName;
|
||||
let directRecipientName = user?.name || initialDirectRecipientName;
|
||||
|
||||
// Ensure typesafety when we add more options.
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
|
||||
@@ -238,7 +238,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
}
|
||||
|
||||
if (templateField.type === FieldType.NAME && directRecipientName === undefined) {
|
||||
directRecipientName === signedFieldValue?.value;
|
||||
directRecipientName = signedFieldValue?.value;
|
||||
}
|
||||
|
||||
const derivedRecipientActionAuth = await validateFieldAuth({
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
|
||||
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
|
||||
|
||||
export type DeleteUserOptions = {
|
||||
id: number;
|
||||
@@ -14,6 +12,30 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
ownedOrganisations: {
|
||||
include: {
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organisationMember: {
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -22,22 +44,36 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
});
|
||||
}
|
||||
|
||||
const serviceAccount = await deletedAccountServiceAccount();
|
||||
// Get team IDs from organisations the user owns.
|
||||
const ownedTeamIds = user.ownedOrganisations.flatMap((org) => org.teams.map((team) => team.id));
|
||||
|
||||
// TODO: Send out cancellations for all pending docs
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
userId: serviceAccount.id,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Get team IDs from organisations the user is a member of (but not owner).
|
||||
const memberTeams = user.organisationMember
|
||||
.filter((member) => member.organisation.ownerUserId !== user.id)
|
||||
.flatMap((member) =>
|
||||
member.organisation.teams.map((team) => ({
|
||||
teamId: team.id,
|
||||
orgOwnerId: member.organisation.ownerUserId,
|
||||
})),
|
||||
);
|
||||
|
||||
// For teams where user is the org owner - orphan their envelopes.
|
||||
await Promise.all(ownedTeamIds.map(async (teamId) => orphanEnvelopes({ teamId })));
|
||||
|
||||
// For teams where user is a member (not owner) - transfer envelopes to team owner.
|
||||
await Promise.all(
|
||||
memberTeams.map(async ({ teamId, orgOwnerId }) => {
|
||||
return prisma.envelope.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
userId: orgOwnerId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return await prisma.user.delete({
|
||||
where: {
|
||||
|
||||
@@ -5,6 +5,20 @@ export const deletedAccountServiceAccount = async () => {
|
||||
where: {
|
||||
email: 'deleted-account@documenso.com',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
ownedOrganisations: {
|
||||
select: {
|
||||
id: true,
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!serviceAccount) {
|
||||
|
||||
Reference in New Issue
Block a user