mirror of
https://github.com/documenso/documenso.git
synced 2025-11-21 04:01:45 +10:00
Merge branch 'main' into feat/signing-reminders
This commit is contained in:
20
packages/lib/server-only/2fa/verify-password.ts
Normal file
20
packages/lib/server-only/2fa/verify-password.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
type VerifyPasswordOptions = {
|
||||
userId: number;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const verifyPassword = async ({ userId, password }: VerifyPasswordOptions) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await compare(password, user.password);
|
||||
};
|
||||
@ -1,13 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export const findSubscriptions = async () => {
|
||||
return prisma.subscription.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
periodEnd: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -29,37 +29,26 @@ export async function getSigningVolume({
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.leftJoin('User as u', 's.userId', 'u.id')
|
||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||
.leftJoin('Document as ud', (join) =>
|
||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Document as d', (join) =>
|
||||
join
|
||||
.onRef('u.id', '=', 'ud.userId')
|
||||
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('ud.deletedAt', 'is', null)
|
||||
.on('ud.teamId', 'is', null),
|
||||
)
|
||||
.leftJoin('Document as td', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'td.teamId')
|
||||
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('td.deletedAt', 'is', null),
|
||||
.onRef('t.id', '=', 'd.teamId')
|
||||
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('d.deletedAt', 'is', null),
|
||||
)
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('u.name', 'ilike', `%${search}%`),
|
||||
eb('u.email', 'ilike', `%${search}%`),
|
||||
eb('t.name', 'ilike', `%${search}%`),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select([
|
||||
's.id as id',
|
||||
's.createdAt as createdAt',
|
||||
's.planId as planId',
|
||||
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
|
||||
.groupBy(['s.id', 'o.name']);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@ -79,15 +68,11 @@ export async function getSigningVolume({
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.leftJoin('User as u', 's.userId', 'u.id')
|
||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('u.name', 'ilike', `%${search}%`),
|
||||
eb('u.email', 'ilike', `%${search}%`),
|
||||
eb('t.name', 'ilike', `%${search}%`),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
|
||||
|
||||
@ -1,50 +1,17 @@
|
||||
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||
import { SubscriptionStatus, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
export const getUsersCount = async () => {
|
||||
return await prisma.user.count();
|
||||
};
|
||||
|
||||
export const getUsersWithSubscriptionsCount = async () => {
|
||||
return await prisma.user.count({
|
||||
export const getOrganisationsWithSubscriptionsCount = async () => {
|
||||
return await prisma.organisation.count({
|
||||
where: {
|
||||
subscriptions: {
|
||||
some: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserWithAtLeastOneDocumentPerMonth = async () => {
|
||||
return await prisma.user.count({
|
||||
where: {
|
||||
documents: {
|
||||
some: {
|
||||
createdAt: {
|
||||
gte: DateTime.now().minus({ months: 1 }).toJSDate(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserWithAtLeastOneDocumentSignedPerMonth = async () => {
|
||||
return await prisma.user.count({
|
||||
where: {
|
||||
documents: {
|
||||
some: {
|
||||
status: {
|
||||
equals: DocumentStatus.COMPLETED,
|
||||
},
|
||||
completedAt: {
|
||||
gte: DateTime.now().minus({ months: 1 }).toJSDate(),
|
||||
},
|
||||
},
|
||||
subscription: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -69,6 +36,8 @@ export const getUserWithSignedDocumentMonthlyGrowth = async () => {
|
||||
COUNT(DISTINCT "Document"."userId") as "count",
|
||||
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."userId" END) as "signed_count"
|
||||
FROM "Document"
|
||||
INNER JOIN "Team" ON "Document"."teamId" = "Team"."id"
|
||||
INNER JOIN "Organisation" ON "Team"."organisationId" = "Organisation"."id"
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
@ -80,3 +49,37 @@ export const getUserWithSignedDocumentMonthlyGrowth = async () => {
|
||||
signed_count: Number(row.signed_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export type GetMonthlyActiveUsersResult = Array<{
|
||||
month: string;
|
||||
count: number;
|
||||
cume_count: number;
|
||||
}>;
|
||||
|
||||
export const getMonthlyActiveUsers = async () => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('UserSecurityAuditLog')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']).as('month'),
|
||||
fn.count('userId').distinct().as('count'),
|
||||
fn
|
||||
.sum(fn.count('userId').distinct())
|
||||
.over((ob) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']) as any),
|
||||
)
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
const result = await qb.execute();
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
cume_count: Number(row.cume_count),
|
||||
}));
|
||||
};
|
||||
|
||||
@ -15,10 +15,11 @@ import { prisma } from '@documenso/prisma';
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
@ -26,6 +27,8 @@ export type CreateDocumentMetaOptions = {
|
||||
password?: string;
|
||||
dateFormat?: string;
|
||||
redirectUrl?: string;
|
||||
emailId?: string | null;
|
||||
emailReplyTo?: string | null;
|
||||
emailSettings?: TDocumentEmailSettings;
|
||||
signingOrder?: DocumentSigningOrder;
|
||||
allowDictateNextSigner?: boolean;
|
||||
@ -50,6 +53,8 @@ export const upsertDocumentMeta = async ({
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
allowDictateNextSigner,
|
||||
emailId,
|
||||
emailReplyTo,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
@ -59,25 +64,14 @@ export const upsertDocumentMeta = async ({
|
||||
reminderInterval,
|
||||
requestMetadata,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
const { documentWhereInput, team } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
@ -91,6 +85,22 @@ export const upsertDocumentMeta = async ({
|
||||
|
||||
const { documentMeta: originalDocumentMeta } = document;
|
||||
|
||||
// Validate the emailId belongs to the organisation.
|
||||
if (emailId) {
|
||||
const email = await prisma.organisationEmail.findFirst({
|
||||
where: {
|
||||
id: emailId,
|
||||
organisationId: team.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const upsertedDocumentMeta = await tx.documentMeta.upsert({
|
||||
where: {
|
||||
@ -106,6 +116,8 @@ export const upsertDocumentMeta = async ({
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
allowDictateNextSigner,
|
||||
emailId,
|
||||
emailReplyTo,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
@ -123,6 +135,8 @@ export const upsertDocumentMeta = async ({
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
allowDictateNextSigner,
|
||||
emailId,
|
||||
emailReplyTo,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
@ -140,6 +141,11 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const authOptions = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
@ -154,6 +160,7 @@ export const completeDocumentWithToken = async ({
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
actionAuth: authOptions.derivedRecipientActionAuth,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@ -6,14 +6,12 @@ import {
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
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';
|
||||
@ -26,21 +24,25 @@ import {
|
||||
} from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentDataId: string;
|
||||
normalizePdf?: boolean;
|
||||
data: {
|
||||
title: string;
|
||||
externalId?: string;
|
||||
visibility?: DocumentVisibility;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes;
|
||||
globalActionAuth?: TDocumentActionAuthTypes;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
formValues?: TDocumentFormValues;
|
||||
recipients: TCreateDocumentV2Request['recipients'];
|
||||
};
|
||||
@ -59,36 +61,28 @@ export const createDocumentV2 = async ({
|
||||
}: CreateDocumentOptions) => {
|
||||
const { title, formValues } = data;
|
||||
|
||||
const team = teamId
|
||||
? await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
members: {
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (teamId !== undefined && !team) {
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (normalizePdf) {
|
||||
const documentData = await prisma.documentData.findFirst({
|
||||
where: {
|
||||
@ -113,35 +107,57 @@ export const createDocumentV2 = async ({
|
||||
}
|
||||
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
globalAccessAuth: data?.globalAccessAuth || null,
|
||||
globalActionAuth: data?.globalActionAuth || null,
|
||||
globalAccessAuth: data?.globalAccessAuth || [],
|
||||
globalActionAuth: data?.globalActionAuth || [],
|
||||
});
|
||||
|
||||
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth);
|
||||
const recipientsHaveActionAuth = data.recipients?.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (authOptions.globalActionAuth || recipientsHaveActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (
|
||||
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
|
||||
!team.organisation.organisationClaim.flags.cfr21
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const { teamRole } = await getMemberRoles({
|
||||
teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
|
||||
|
||||
const emailId = meta?.emailId;
|
||||
|
||||
// Validate that the email ID belongs to the organisation.
|
||||
if (emailId) {
|
||||
const email = await prisma.organisationEmail.findFirst({
|
||||
where: {
|
||||
id: emailId,
|
||||
organisationId: team.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
if (!email) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const visibility = determineDocumentVisibility(
|
||||
team?.teamGlobalSettings?.documentVisibility,
|
||||
team?.members[0].role ?? TeamMemberRole.MEMBER,
|
||||
);
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId: data.externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
@ -151,18 +167,7 @@ export const createDocumentV2 = async ({
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
create: {
|
||||
...meta,
|
||||
signingOrder: meta?.signingOrder || undefined,
|
||||
emailSettings: meta?.emailSettings || undefined,
|
||||
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
|
||||
typedSignatureEnabled:
|
||||
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
|
||||
uploadSignatureEnabled:
|
||||
meta?.uploadSignatureEnabled ?? team?.teamGlobalSettings?.uploadSignatureEnabled,
|
||||
drawSignatureEnabled:
|
||||
meta?.drawSignatureEnabled ?? team?.teamGlobalSettings?.drawSignatureEnabled,
|
||||
},
|
||||
create: extractDerivedDocumentMeta(settings, meta),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -170,8 +175,8 @@ export const createDocumentV2 = async ({
|
||||
await Promise.all(
|
||||
(data.recipients || []).map(async (recipient) => {
|
||||
const recipientAuthOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipient.accessAuth || null,
|
||||
actionAuth: recipient.actionAuth || null,
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
});
|
||||
|
||||
await tx.recipient.create({
|
||||
@ -207,7 +212,7 @@ export const createDocumentV2 = async ({
|
||||
}),
|
||||
);
|
||||
|
||||
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs?
|
||||
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
@ -232,6 +237,7 @@ export const createDocumentV2 = async ({
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,33 +1,39 @@
|
||||
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
|
||||
import type { Team, TeamGlobalSettings } from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type { DocumentVisibility } from '@prisma/client';
|
||||
|
||||
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 { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
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 { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
externalId?: string | null;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentDataId: string;
|
||||
formValues?: Record<string, string | number | boolean>;
|
||||
normalizePdf?: boolean;
|
||||
timezone?: string;
|
||||
userTimezone?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const createDocument = async ({
|
||||
@ -40,52 +46,39 @@ export const createDocument = async ({
|
||||
formValues,
|
||||
requestMetadata,
|
||||
timezone,
|
||||
userTimezone,
|
||||
folderId,
|
||||
}: CreateDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
teamMembers: {
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (
|
||||
teamId !== undefined &&
|
||||
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
let folderVisibility: DocumentVisibility | undefined;
|
||||
|
||||
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
|
||||
let userTeamRole: TeamMemberRole | undefined;
|
||||
|
||||
if (teamId) {
|
||||
const teamWithUserRole = await prisma.team.findFirstOrThrow({
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
members: {
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
visibility: true,
|
||||
},
|
||||
});
|
||||
|
||||
team = teamWithUserRole;
|
||||
userTeamRole = teamWithUserRole.members[0]?.role;
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
folderVisibility = folder.visibility;
|
||||
}
|
||||
|
||||
if (normalizePdf) {
|
||||
@ -111,28 +104,29 @@ export const createDocument = async ({
|
||||
}
|
||||
}
|
||||
|
||||
// userTimezone is last because it's always passed in regardless of the organisation/team settings
|
||||
// for uploads from the frontend
|
||||
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
visibility: determineDocumentVisibility(
|
||||
team?.teamGlobalSettings?.documentVisibility,
|
||||
userTeamRole ?? TeamMemberRole.MEMBER,
|
||||
),
|
||||
folderId,
|
||||
visibility:
|
||||
folderVisibility ??
|
||||
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
create: {
|
||||
language: team?.teamGlobalSettings?.documentLanguage,
|
||||
timezone: timezone,
|
||||
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled ?? true,
|
||||
uploadSignatureEnabled: team?.teamGlobalSettings?.uploadSignatureEnabled ?? true,
|
||||
drawSignatureEnabled: team?.teamGlobalSettings?.drawSignatureEnabled ?? true,
|
||||
},
|
||||
create: extractDerivedDocumentMeta(settings, {
|
||||
timezone: timezoneToUse,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type {
|
||||
Document,
|
||||
DocumentMeta,
|
||||
Recipient,
|
||||
Team,
|
||||
TeamGlobalSettings,
|
||||
User,
|
||||
} from '@prisma/client';
|
||||
import type { Document, DocumentMeta, Recipient, User } from '@prisma/client';
|
||||
import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
@ -17,7 +10,6 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
@ -29,13 +21,14 @@ import type { ApiRequestMetadata } from '../../universal/extract-request-metadat
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
@ -64,23 +57,26 @@ export const deleteDocument = async ({
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
members: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isUserTeamMember = await getMemberRoles({
|
||||
teamId: document.teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
const isUserOwner = document.userId === userId;
|
||||
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
|
||||
const userRecipient = document.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
|
||||
@ -94,7 +90,6 @@ export const deleteDocument = async ({
|
||||
await handleDocumentOwnerDelete({
|
||||
document,
|
||||
user,
|
||||
team: document.team,
|
||||
requestMetadata,
|
||||
});
|
||||
}
|
||||
@ -142,11 +137,6 @@ type HandleDocumentOwnerDeleteOptions = {
|
||||
recipients: Recipient[];
|
||||
documentMeta: DocumentMeta | null;
|
||||
};
|
||||
team?:
|
||||
| (Team & {
|
||||
teamGlobalSettings?: TeamGlobalSettings | null;
|
||||
})
|
||||
| null;
|
||||
user: User;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -154,13 +144,21 @@ type HandleDocumentOwnerDeleteOptions = {
|
||||
const handleDocumentOwnerDelete = async ({
|
||||
document,
|
||||
user,
|
||||
team,
|
||||
requestMetadata,
|
||||
}: HandleDocumentOwnerDeleteOptions) => {
|
||||
if (document.deletedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
// Soft delete completed documents.
|
||||
if (isDocumentCompleted(document.status)) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
@ -235,30 +233,24 @@ const handleDocumentOwnerDelete = async ({
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document Cancelled`),
|
||||
html,
|
||||
text,
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
import { DocumentSource, type Prisma } from '@prisma/client';
|
||||
import type { Prisma, Recipient } from '@prisma/client';
|
||||
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { omit } from 'remeda';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { nanoid, prefixedId } from '../../universal/id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export interface DuplicateDocumentOptions {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export const duplicateDocument = async ({
|
||||
@ -16,7 +24,7 @@ export const duplicateDocument = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: DuplicateDocumentOptions) => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
@ -34,14 +42,16 @@ export const duplicateDocument = async ({
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
documentMeta: {
|
||||
authOptions: true,
|
||||
visibility: true,
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
select: {
|
||||
message: true,
|
||||
subject: true,
|
||||
dateFormat: true,
|
||||
password: true,
|
||||
timezone: true,
|
||||
redirectUrl: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
signingOrder: true,
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -53,38 +63,88 @@ export const duplicateDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const createDocumentArguments: Prisma.DocumentCreateArgs = {
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
title: document.title,
|
||||
user: {
|
||||
connect: {
|
||||
id: document.userId,
|
||||
},
|
||||
},
|
||||
documentData: {
|
||||
create: {
|
||||
...document.documentData,
|
||||
data: document.documentData.initialData,
|
||||
},
|
||||
},
|
||||
documentMeta: {
|
||||
create: {
|
||||
...document.documentMeta,
|
||||
},
|
||||
},
|
||||
source: DocumentSource.DOCUMENT,
|
||||
type: document.documentData.type,
|
||||
data: document.documentData.initialData,
|
||||
initialData: document.documentData.initialData,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (teamId !== undefined) {
|
||||
createDocumentArguments.data.team = {
|
||||
connect: {
|
||||
id: teamId,
|
||||
let documentMeta: Prisma.DocumentCreateArgs['data']['documentMeta'] | undefined = undefined;
|
||||
|
||||
if (document.documentMeta) {
|
||||
documentMeta = {
|
||||
create: {
|
||||
...omit(document.documentMeta, ['id', 'documentId']),
|
||||
emailSettings: document.documentMeta.emailSettings || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createdDocument = await prisma.document.create(createDocumentArguments);
|
||||
const createdDocument = await prisma.document.create({
|
||||
data: {
|
||||
userId: document.userId,
|
||||
teamId: teamId,
|
||||
title: document.title,
|
||||
documentDataId: documentData.id,
|
||||
authOptions: document.authOptions || undefined,
|
||||
visibility: document.visibility,
|
||||
qrToken: prefixedId('qr'),
|
||||
documentMeta,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipientsToCreate = document.recipients.map((recipient) => ({
|
||||
documentId: createdDocument.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
fields: {
|
||||
createMany: {
|
||||
data: recipient.fields.map((field) => ({
|
||||
documentId: createdDocument.id,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const recipients: Recipient[] = [];
|
||||
|
||||
for (const recipientData of recipientsToCreate) {
|
||||
const newRecipient = await prisma.recipient.create({
|
||||
data: recipientData,
|
||||
});
|
||||
|
||||
recipients.push(newRecipient);
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse({
|
||||
...mapDocumentToWebhookDocumentPayload(createdDocument),
|
||||
recipients,
|
||||
documentMeta: createdDocument.documentMeta,
|
||||
}),
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: createdDocument.id,
|
||||
|
||||
@ -6,10 +6,11 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export interface FindDocumentAuditLogsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@ -34,25 +35,14 @@ export const findDocumentAuditLogs = async ({
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
|
||||
@ -9,6 +9,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { type FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
|
||||
@ -27,6 +28,7 @@ export type FindDocumentsOptions = {
|
||||
period?: PeriodSelectorValue;
|
||||
senderIds?: number[];
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const findDocuments = async ({
|
||||
@ -41,6 +43,7 @@ export const findDocuments = async ({
|
||||
period,
|
||||
senderIds,
|
||||
query = '',
|
||||
folderId,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@ -51,32 +54,15 @@ export const findDocuments = async ({
|
||||
let team = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
team = await getTeamById({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
const teamMemberRole = team?.members[0].role ?? null;
|
||||
const teamMemberRole = team?.currentTeamRole ?? null;
|
||||
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
@ -120,10 +106,10 @@ export const findDocuments = async ({
|
||||
},
|
||||
];
|
||||
|
||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
|
||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
|
||||
|
||||
if (team) {
|
||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
|
||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
|
||||
}
|
||||
|
||||
if (filters === null) {
|
||||
@ -227,6 +213,12 @@ export const findDocuments = async ({
|
||||
};
|
||||
}
|
||||
|
||||
if (folderId !== undefined) {
|
||||
whereClause.folderId = folderId;
|
||||
} else {
|
||||
whereClause.folderId = null;
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
where: whereClause,
|
||||
@ -273,13 +265,17 @@ export const findDocuments = async ({
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
|
||||
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
const findDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
user: User,
|
||||
folderId?: string | null,
|
||||
) => {
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
@ -288,6 +284,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@ -296,6 +293,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@ -315,15 +313,14 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@ -336,6 +333,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@ -343,8 +341,8 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
@ -353,6 +351,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@ -360,8 +359,8 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
@ -371,6 +370,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@ -410,6 +410,7 @@ const findTeamDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
team: Team & { teamEmail: TeamEmail | null },
|
||||
visibilityFilters: Prisma.DocumentWhereInput[],
|
||||
folderId?: string,
|
||||
) => {
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
|
||||
@ -420,6 +421,7 @@ const findTeamDocumentsFilter = (
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
folderId: folderId,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
@ -437,6 +439,7 @@ const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
});
|
||||
|
||||
// Filter to display all documents that have been sent by the team email.
|
||||
@ -445,6 +448,7 @@ const findTeamDocumentsFilter = (
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
@ -470,6 +474,7 @@ const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
};
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => {
|
||||
@ -479,6 +484,7 @@ const findTeamDocumentsFilter = (
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -490,6 +496,7 @@ const findTeamDocumentsFilter = (
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
@ -502,6 +509,7 @@ const findTeamDocumentsFilter = (
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -521,12 +529,14 @@ const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -11,18 +11,27 @@ import { getTeamById } from '../team/get-team';
|
||||
export type GetDocumentByIdOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
export const getDocumentById = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
}: GetDocumentByIdOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
where: {
|
||||
...documentWhereInput,
|
||||
folderId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
@ -59,18 +68,7 @@ export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumen
|
||||
export type GetDocumentWhereInputOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
|
||||
/**
|
||||
* Whether to return a filter that allows access to both the user and team documents.
|
||||
* This only applies if `teamId` is passed in.
|
||||
*
|
||||
* If true, and `teamId` is passed in, the filter will allow both team and user documents.
|
||||
* If false, and `teamId` is passed in, the filter will only allow team documents.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
overlapUserTeamScope?: boolean;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -82,42 +80,55 @@ export const getDocumentWhereInput = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
overlapUserTeamScope = false,
|
||||
}: GetDocumentWhereInputOptions) => {
|
||||
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamId === undefined || !documentWhereInput.OR) {
|
||||
return documentWhereInput;
|
||||
}
|
||||
|
||||
const team = await getTeamById({ teamId, userId });
|
||||
|
||||
// Allow access to team and user documents.
|
||||
if (overlapUserTeamScope) {
|
||||
documentWhereInput.OR.push({
|
||||
teamId: team.id,
|
||||
});
|
||||
}
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Allow access to only team documents.
|
||||
if (!overlapUserTeamScope) {
|
||||
documentWhereInput.OR = [
|
||||
{
|
||||
teamId: team.id,
|
||||
const teamVisibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
])
|
||||
.with(TeamMemberRole.MANAGER, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
const documentOrInput: Prisma.DocumentWhereInput[] = [
|
||||
// Allow access if they own the document.
|
||||
{
|
||||
userId,
|
||||
},
|
||||
// Or, if they belong to the team that the document is associated with.
|
||||
{
|
||||
visibility: {
|
||||
in: teamVisibilityFilters,
|
||||
},
|
||||
];
|
||||
}
|
||||
teamId: team.id,
|
||||
},
|
||||
// Or, if they are a recipient of the document.
|
||||
{
|
||||
status: {
|
||||
not: DocumentStatus.DRAFT,
|
||||
},
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Allow access to documents sent to or from the team email.
|
||||
if (team.teamEmail) {
|
||||
documentWhereInput.OR.push(
|
||||
documentOrInput.push(
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
@ -133,42 +144,13 @@ export const getDocumentWhereInput = async ({
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const visibilityFilters = [
|
||||
...match(team.currentTeamMember?.role)
|
||||
.with(TeamMemberRole.ADMIN, () => [
|
||||
{ visibility: DocumentVisibility.EVERYONE },
|
||||
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
{ visibility: DocumentVisibility.ADMIN },
|
||||
])
|
||||
.with(TeamMemberRole.MANAGER, () => [
|
||||
{ visibility: DocumentVisibility.EVERYONE },
|
||||
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
])
|
||||
.otherwise(() => [{ visibility: DocumentVisibility.EVERYONE }]),
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
|
||||
id: documentId,
|
||||
OR: documentOrInput,
|
||||
};
|
||||
|
||||
return {
|
||||
...documentWhereInput,
|
||||
OR: [...visibilityFilters],
|
||||
documentWhereInput,
|
||||
team,
|
||||
};
|
||||
};
|
||||
|
||||
@ -85,11 +85,6 @@ export const getDocumentAndSenderByToken = async ({
|
||||
select: {
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSenderDetails: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -17,6 +17,7 @@ export const getDocumentCertificateAuditLogs = async ({
|
||||
in: [
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
],
|
||||
@ -36,6 +37,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_FIELD_INSERTED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&
|
||||
|
||||
@ -6,27 +6,57 @@ import { getDocumentWhereInput } from './get-document-by-id';
|
||||
export type GetDocumentWithDetailsByIdOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getDocumentWithDetailsById = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
}: GetDocumentWithDetailsByIdOptions) => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
where: {
|
||||
...documentWhereInput,
|
||||
folderId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
fields: {
|
||||
include: {
|
||||
signature: true,
|
||||
recipient: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -11,13 +11,20 @@ import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-d
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: User;
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => {
|
||||
export const getStats = async ({
|
||||
user,
|
||||
period,
|
||||
search = '',
|
||||
folderId,
|
||||
...options
|
||||
}: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
|
||||
if (period) {
|
||||
@ -37,8 +44,9 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
||||
currentUserEmail: user.email,
|
||||
userId: user.id,
|
||||
search,
|
||||
folderId,
|
||||
})
|
||||
: getCounts({ user, createdAt, search }));
|
||||
: getCounts({ user, createdAt, search, folderId }));
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
@ -81,12 +89,13 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
||||
};
|
||||
|
||||
type GetCountsOption = {
|
||||
user: User;
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
@ -95,6 +104,8 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
return Promise.all([
|
||||
// Owner counts.
|
||||
prisma.document.groupBy({
|
||||
@ -105,9 +116,8 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
where: {
|
||||
userId: user.id,
|
||||
createdAt,
|
||||
teamId: null,
|
||||
deletedAt: null,
|
||||
AND: [searchFilter],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
// Not signed counts.
|
||||
@ -126,7 +136,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
AND: [searchFilter],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
// Has signed counts.
|
||||
@ -164,7 +174,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@ -179,10 +189,11 @@ type GetTeamCountsOption = {
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
currentTeamMemberRole?: TeamMemberRole;
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
const { createdAt, teamId, teamEmail } = options;
|
||||
const { createdAt, teamId, teamEmail, folderId } = options;
|
||||
|
||||
const senderIds = options.senderIds ?? [];
|
||||
|
||||
@ -206,6 +217,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
createdAt,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
folderId,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
@ -278,6 +290,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
where: {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
@ -298,6 +311,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
where: {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@ -319,7 +333,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
|
||||
import { verifyPassword } from '../2fa/verify-password';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
@ -60,23 +61,26 @@ export const isRecipientAuthorized = async ({
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const authMethod: TDocumentAuth | null =
|
||||
const authMethods: TDocumentAuth[] =
|
||||
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
|
||||
|
||||
// Early true return when auth is not required.
|
||||
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
|
||||
if (
|
||||
authMethods.length === 0 ||
|
||||
authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create auth options when none are passed for account.
|
||||
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
|
||||
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
|
||||
authOptions = {
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
};
|
||||
}
|
||||
|
||||
// Authentication required does not match provided method.
|
||||
if (!authOptions || authOptions.type !== authMethod || !userId) {
|
||||
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -117,6 +121,15 @@ export const isRecipientAuthorized = async ({
|
||||
window: 10, // 5 minutes worth of tokens
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
|
||||
return await verifyPassword({
|
||||
userId,
|
||||
password,
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
|
||||
return true;
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -160,7 +173,7 @@ const verifyPasskey = async ({
|
||||
}: VerifyPasskeyOptions): Promise<void> => {
|
||||
const passkey = await prisma.passkey.findFirst({
|
||||
where: {
|
||||
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
|
||||
credentialId: new Uint8Array(Buffer.from(authenticationResponse.id, 'base64')),
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type MoveDocumentToTeamOptions = {
|
||||
documentId: number;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const moveDocumentToTeam = async ({
|
||||
documentId,
|
||||
teamId,
|
||||
userId,
|
||||
requestMetadata,
|
||||
}: MoveDocumentToTeamOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found or already associated with a team.',
|
||||
});
|
||||
}
|
||||
|
||||
const team = await tx.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'This team does not exist, or you are not a member of this team.',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await tx.document.update({
|
||||
where: { id: documentId },
|
||||
data: { teamId },
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
|
||||
documentId: updatedDocument.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
movedByUserId: userId,
|
||||
fromPersonalAccount: true,
|
||||
toTeamId: teamId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
});
|
||||
};
|
||||
@ -1,12 +1,10 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { DocumentStatus, OrganisationType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
@ -22,14 +20,14 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
recipients: number[];
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
@ -46,7 +44,7 @@ export const resendDocument = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const documentWhereInput: Prisma.DocumentWhereUniqueInput = await getDocumentWhereInput({
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
@ -55,27 +53,18 @@ export const resendDocument = async ({
|
||||
const document = await prisma.document.findUnique({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
id: {
|
||||
in: recipients,
|
||||
},
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
const isTeamDocument = document?.team !== null;
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
@ -93,6 +82,11 @@ export const resendDocument = async ({
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
const recipientsToRemind = document.recipients.filter(
|
||||
(recipient) =>
|
||||
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
|
||||
);
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
@ -101,13 +95,23 @@ export const resendDocument = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
document.recipients.map(async (recipient) => {
|
||||
recipientsToRemind.map(async (recipient) => {
|
||||
if (recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||
|
||||
@ -128,7 +132,7 @@ export const resendDocument = async ({
|
||||
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
|
||||
}
|
||||
|
||||
if (isTeamDocument && document.team) {
|
||||
if (organisationType === OrganisationType.ORGANISATION) {
|
||||
emailSubject = i18n._(
|
||||
msg`Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`,
|
||||
);
|
||||
@ -151,27 +155,26 @@ export const resendDocument = async ({
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: isTeamDocument ? document.team?.teamEmail?.email || user.email : user.email,
|
||||
inviterEmail:
|
||||
organisationType === OrganisationType.ORGANISATION
|
||||
? document.team?.teamEmail?.email || user.email
|
||||
: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
|
||||
role: recipient.role,
|
||||
selfSigner,
|
||||
isTeamInvite: isTeamDocument,
|
||||
organisationType,
|
||||
teamName: document.team?.name,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
@ -184,10 +187,8 @@ export const resendDocument = async ({
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(
|
||||
i18n._(msg`Reminder: ${customEmail.subject}`),
|
||||
@ -17,12 +17,15 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
|
||||
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
|
||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { flattenForm } from '../pdf/flatten-form';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { legacy_insertFieldInPDF } from '../pdf/legacy-insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
@ -47,15 +50,6 @@ export const sealDocument = async ({
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -65,6 +59,11 @@ export const sealDocument = async ({
|
||||
throw new Error(`Document ${document.id} has no document data`);
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
@ -115,19 +114,35 @@ export const sealDocument = async ({
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const pdfData = await getFileServerSide(documentData);
|
||||
|
||||
const certificateData =
|
||||
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
|
||||
? await getCertificatePdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
const certificateData = settings.includeSigningCertificate
|
||||
? await getCertificatePdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch((e) => {
|
||||
console.log('Failed to get certificate PDF');
|
||||
console.error(e);
|
||||
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const auditLogData = settings.includeAuditLog
|
||||
? await getAuditLogsPdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch((e) => {
|
||||
console.log('Failed to get audit logs PDF');
|
||||
console.error(e);
|
||||
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
normalizeSignatureAppearances(doc);
|
||||
flattenForm(doc);
|
||||
await flattenForm(doc);
|
||||
flattenAnnotations(doc);
|
||||
|
||||
// Add rejection stamp if the document is rejected
|
||||
@ -145,12 +160,24 @@ export const sealDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLogData) {
|
||||
const auditLog = await PDFDocument.load(auditLogData);
|
||||
|
||||
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
|
||||
|
||||
auditLogPages.forEach((page) => {
|
||||
doc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
await insertFieldInPDF(doc, field);
|
||||
document.useLegacyFieldInsertion
|
||||
? await legacy_insertFieldInPDF(doc, field)
|
||||
: await insertFieldInPDF(doc, field);
|
||||
}
|
||||
|
||||
// Re-flatten post-insertion to handle fields that create arcoFields
|
||||
flattenForm(doc);
|
||||
await flattenForm(doc);
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
|
||||
|
||||
@ -3,7 +3,11 @@ import type { Document, Recipient, User } from '@prisma/client';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import {
|
||||
buildTeamWhereQuery,
|
||||
formatDocumentsPath,
|
||||
getHighestTeamRoleInGroup,
|
||||
} from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type SearchDocumentsWithKeywordOptions = {
|
||||
@ -84,16 +88,7 @@ export const searchDocumentsWithKeyword = async ({
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
teamId: {
|
||||
not: null,
|
||||
},
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
team: buildTeamWhereQuery({ teamId: undefined, userId }),
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
@ -101,16 +96,7 @@ export const searchDocumentsWithKeyword = async ({
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
teamId: {
|
||||
not: null,
|
||||
},
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
team: buildTeamWhereQuery({ teamId: undefined, userId }),
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
@ -120,12 +106,17 @@ export const searchDocumentsWithKeyword = async ({
|
||||
team: {
|
||||
select: {
|
||||
url: true,
|
||||
members: {
|
||||
teamGroups: {
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -147,7 +138,9 @@ export const searchDocumentsWithKeyword = async ({
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamMemberRole = document.team?.members[0]?.role;
|
||||
const teamMemberRole = getHighestTeamRoleInGroup(
|
||||
document.team.teamGroups.filter((tg) => tg.teamId === document.teamId),
|
||||
);
|
||||
|
||||
if (!teamMemberRole) {
|
||||
return false;
|
||||
|
||||
@ -14,11 +14,10 @@ import { extractDerivedDocumentEmailSettings } from '../../types/document-email'
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { env } from '../../utils/env';
|
||||
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { formatDocumentsPath } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
@ -39,7 +38,6 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -55,6 +53,15 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
const { user: owner } = document;
|
||||
|
||||
const completedDocument = await getFileServerSide(document.documentData);
|
||||
@ -71,8 +78,6 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
}`;
|
||||
}
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
|
||||
const emailSettings = extractDerivedDocumentEmailSettings(document.documentMeta);
|
||||
const isDocumentCompletedEmailEnabled = emailSettings.documentCompleted;
|
||||
const isOwnerDocumentCompletedEmailEnabled = emailSettings.ownerDocumentCompleted;
|
||||
@ -93,19 +98,17 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
downloadLink: documentOwnerDownloadLink,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: [
|
||||
{
|
||||
@ -113,10 +116,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
address: owner.email,
|
||||
},
|
||||
],
|
||||
from: {
|
||||
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
|
||||
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Signing Complete!`),
|
||||
html,
|
||||
text,
|
||||
@ -170,19 +171,17 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: [
|
||||
{
|
||||
@ -190,10 +189,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
address: recipient.email,
|
||||
},
|
||||
],
|
||||
from: {
|
||||
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
|
||||
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject:
|
||||
isDirectTemplate && document.documentMeta?.subject
|
||||
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
|
||||
|
||||
@ -10,9 +10,8 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { env } from '../../utils/env';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface SendDeleteEmailOptions {
|
||||
documentId: number;
|
||||
@ -27,11 +26,6 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
|
||||
include: {
|
||||
user: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -49,6 +43,15 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
const { email, name } = document.user;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
@ -59,30 +62,23 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance();
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name: name || '',
|
||||
},
|
||||
from: {
|
||||
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
|
||||
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
|
||||
},
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`Document Deleted!`),
|
||||
html,
|
||||
text,
|
||||
|
||||
@ -23,11 +23,12 @@ import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
sendEmail?: boolean;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -39,25 +40,14 @@ export const sendDocument = async ({
|
||||
sendEmail,
|
||||
requestMetadata,
|
||||
}: SendDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: {
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
|
||||
@ -9,9 +9,8 @@ import { prisma } from '@documenso/prisma';
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { env } from '../../utils/env';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface SendPendingEmailOptions {
|
||||
documentId: number;
|
||||
@ -35,11 +34,6 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -51,6 +45,15 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).documentPending;
|
||||
@ -70,30 +73,24 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
|
||||
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Waiting for others to complete signing.`),
|
||||
html,
|
||||
text,
|
||||
|
||||
@ -9,14 +9,13 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export type SuperDeleteDocumentOptions = {
|
||||
id: number;
|
||||
@ -32,11 +31,6 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
user: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -46,6 +40,15 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
});
|
||||
}
|
||||
|
||||
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
const { status, user } = document;
|
||||
|
||||
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
@ -72,30 +75,26 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
const lang = document.documentMeta?.language ?? settings.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
lang,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document Cancelled`),
|
||||
html,
|
||||
text,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
@ -12,17 +12,19 @@ import { prisma } from '@documenso/prisma';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type UpdateDocumentOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
data?: {
|
||||
title?: string;
|
||||
externalId?: string | null;
|
||||
visibility?: DocumentVisibility | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
useLegacyFieldInsertion?: boolean;
|
||||
};
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -34,34 +36,20 @@ export const updateDocument = async ({
|
||||
data,
|
||||
requestMetadata,
|
||||
}: UpdateDocumentOptions) => {
|
||||
const { documentWhereInput, team } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
organisation: {
|
||||
select: {
|
||||
role: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -75,45 +63,42 @@ export const updateDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
const currentUserRole = document.team?.members[0]?.role;
|
||||
const isDocumentOwner = document.userId === userId;
|
||||
const requestedVisibility = data?.visibility;
|
||||
const isDocumentOwner = document.userId === userId;
|
||||
const requestedVisibility = data?.visibility;
|
||||
|
||||
if (!isDocumentOwner) {
|
||||
match(currentUserRole)
|
||||
.with(TeamMemberRole.ADMIN, () => true)
|
||||
.with(TeamMemberRole.MANAGER, () => {
|
||||
const allowedVisibilities: DocumentVisibility[] = [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
];
|
||||
if (!isDocumentOwner) {
|
||||
match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => true)
|
||||
.with(TeamMemberRole.MANAGER, () => {
|
||||
const allowedVisibilities: DocumentVisibility[] = [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
];
|
||||
|
||||
if (
|
||||
!allowedVisibilities.includes(document.visibility) ||
|
||||
(requestedVisibility && !allowedVisibilities.includes(requestedVisibility))
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document visibility',
|
||||
});
|
||||
}
|
||||
})
|
||||
.with(TeamMemberRole.MEMBER, () => {
|
||||
if (
|
||||
document.visibility !== DocumentVisibility.EVERYONE ||
|
||||
(requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document visibility',
|
||||
});
|
||||
}
|
||||
})
|
||||
.otherwise(() => {
|
||||
if (
|
||||
!allowedVisibilities.includes(document.visibility) ||
|
||||
(requestedVisibility && !allowedVisibilities.includes(requestedVisibility))
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document',
|
||||
message: 'You do not have permission to update the document visibility',
|
||||
});
|
||||
}
|
||||
})
|
||||
.with(TeamMemberRole.MEMBER, () => {
|
||||
if (
|
||||
document.visibility !== DocumentVisibility.EVERYONE ||
|
||||
(requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document visibility',
|
||||
});
|
||||
}
|
||||
})
|
||||
.otherwise(() => {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If no data just return the document since this function is normally chained after a meta update.
|
||||
@ -135,25 +120,20 @@ export const updateDocument = async ({
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (newGlobalActionAuth.length > 0 && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === undefined || data.title === document.title;
|
||||
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
|
||||
const isGlobalAccessSame =
|
||||
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
|
||||
documentGlobalAccessAuth === undefined ||
|
||||
isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
|
||||
const isGlobalActionSame =
|
||||
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
|
||||
documentGlobalActionAuth === undefined ||
|
||||
isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
|
||||
const isDocumentVisibilitySame =
|
||||
data.visibility === undefined || data.visibility === document.visibility;
|
||||
|
||||
@ -236,7 +216,7 @@ export const updateDocument = async ({
|
||||
}
|
||||
|
||||
// Early return if nothing is required.
|
||||
if (auditLogs.length === 0) {
|
||||
if (auditLogs.length === 0 && data.useLegacyFieldInsertion === undefined) {
|
||||
return document;
|
||||
}
|
||||
|
||||
@ -254,6 +234,7 @@ export const updateDocument = async ({
|
||||
title: data.title,
|
||||
externalId: data.externalId,
|
||||
visibility: data.visibility as DocumentVisibility,
|
||||
useLegacyFieldInsertion: data.useLegacyFieldInsertion,
|
||||
authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type UpdateTitleOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentId: number;
|
||||
title: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const updateTitle = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
title,
|
||||
requestMetadata,
|
||||
}: UpdateTitleOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (document.title === title) {
|
||||
return document;
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Instead of doing everything in a transaction we can use our knowledge
|
||||
// of the current document title to ensure we aren't performing a conflicting
|
||||
// update.
|
||||
const updatedDocument = await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
title: document.title,
|
||||
},
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: document.title,
|
||||
to: updatedDocument.title,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
});
|
||||
};
|
||||
@ -3,7 +3,6 @@ import { FieldType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
|
||||
export type ValidateFieldAuthOptions = {
|
||||
@ -26,14 +25,9 @@ export const validateFieldAuth = async ({
|
||||
userId,
|
||||
authOptions,
|
||||
}: ValidateFieldAuthOptions) => {
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: documentAuthOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
// Override all non-signature fields to not require any auth.
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isValid = await isRecipientAuthorized({
|
||||
@ -50,5 +44,5 @@ export const validateFieldAuth = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return derivedRecipientActionAuth;
|
||||
return authOptions?.type;
|
||||
};
|
||||
|
||||
@ -15,7 +15,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type ViewedDocumentOptions = {
|
||||
token: string;
|
||||
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
recipientAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@ -27,7 +27,6 @@ export const viewedDocument = async ({
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
},
|
||||
});
|
||||
|
||||
@ -37,6 +36,30 @@ export const viewedDocument = async ({
|
||||
|
||||
const { documentId } = recipient;
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED,
|
||||
documentId,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
accessAuth: recipientAccessAuth ?? [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Early return if already opened.
|
||||
if (recipient.readStatus === ReadStatus.OPENED) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
@ -63,7 +86,7 @@ export const viewedDocument = async ({
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
accessAuth: recipientAccessAuth || undefined,
|
||||
accessAuth: recipientAccessAuth ?? [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
235
packages/lib/server-only/email/get-email-context.ts
Normal file
235
packages/lib/server-only/email/get-email-context.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import type { BrandingSettings } from '@documenso/email/providers/branding';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type {
|
||||
DocumentMeta,
|
||||
EmailDomain,
|
||||
Organisation,
|
||||
OrganisationEmail,
|
||||
OrganisationType,
|
||||
} from '@documenso/prisma/client';
|
||||
import {
|
||||
EmailDomainStatus,
|
||||
type OrganisationClaim,
|
||||
type OrganisationGlobalSettings,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
organisationGlobalSettingsToBranding,
|
||||
teamGlobalSettingsToBranding,
|
||||
} from '../../utils/team-global-settings-to-branding';
|
||||
import { extractDerivedTeamSettings } from '../../utils/teams';
|
||||
|
||||
type EmailMetaOption = Partial<Pick<DocumentMeta, 'emailId' | 'emailReplyTo' | 'language'>>;
|
||||
|
||||
type BaseGetEmailContextOptions = {
|
||||
/**
|
||||
* The source to extract the email context from.
|
||||
* - "Team" will use the team settings followed by the inherited organisation settings
|
||||
* - "Organisation" will use the organisation settings
|
||||
*/
|
||||
source:
|
||||
| {
|
||||
type: 'team';
|
||||
teamId: number;
|
||||
}
|
||||
| {
|
||||
type: 'organisation';
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The email type being sent, used to determine what email sender and language to use.
|
||||
* - INTERNAL: Emails to users, such as team invites, etc.
|
||||
* - RECIPIENT: Emails to recipients, such as document sent, document signed, etc.
|
||||
*/
|
||||
emailType: 'INTERNAL' | 'RECIPIENT';
|
||||
};
|
||||
|
||||
type InternalGetEmailContextOptions = BaseGetEmailContextOptions & {
|
||||
emailType: 'INTERNAL';
|
||||
meta?: EmailMetaOption | null;
|
||||
};
|
||||
|
||||
type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
|
||||
emailType: 'RECIPIENT';
|
||||
|
||||
/**
|
||||
* Force meta options as a typesafe way to ensure developers don't forget to
|
||||
* pass it in if it is available.
|
||||
*/
|
||||
meta: EmailMetaOption | null | undefined;
|
||||
};
|
||||
|
||||
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
|
||||
|
||||
type EmailContextResponse = {
|
||||
allowedEmails: OrganisationEmail[];
|
||||
branding: BrandingSettings;
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>;
|
||||
claims: OrganisationClaim;
|
||||
organisationType: OrganisationType;
|
||||
senderEmail: {
|
||||
name: string;
|
||||
address: string;
|
||||
};
|
||||
replyToEmail: string | undefined;
|
||||
emailLanguage: string;
|
||||
};
|
||||
|
||||
export const getEmailContext = async (
|
||||
options: GetEmailContextOptions,
|
||||
): Promise<EmailContextResponse> => {
|
||||
const { source, meta } = options;
|
||||
|
||||
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
|
||||
|
||||
if (source.type === 'organisation') {
|
||||
emailContext = await handleOrganisationEmailContext(source.organisationId);
|
||||
} else {
|
||||
emailContext = await handleTeamEmailContext(source.teamId);
|
||||
}
|
||||
|
||||
const emailLanguage = meta?.language || emailContext.settings.documentLanguage;
|
||||
|
||||
// Immediate return for internal emails.
|
||||
if (options.emailType === 'INTERNAL') {
|
||||
return {
|
||||
...emailContext,
|
||||
senderEmail: DOCUMENSO_INTERNAL_EMAIL,
|
||||
replyToEmail: undefined,
|
||||
emailLanguage, // Not sure if we want to use this for internal emails.
|
||||
};
|
||||
}
|
||||
|
||||
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
|
||||
const senderEmailId = meta?.emailId === null ? null : emailContext.settings.emailId;
|
||||
|
||||
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);
|
||||
|
||||
// Reset the emailId to null if not found.
|
||||
if (!foundSenderEmail) {
|
||||
emailContext.settings.emailId = null;
|
||||
}
|
||||
|
||||
const senderEmail = foundSenderEmail
|
||||
? {
|
||||
name: foundSenderEmail.emailName,
|
||||
address: foundSenderEmail.email,
|
||||
}
|
||||
: DOCUMENSO_INTERNAL_EMAIL;
|
||||
|
||||
return {
|
||||
...emailContext,
|
||||
senderEmail,
|
||||
replyToEmail,
|
||||
emailLanguage,
|
||||
};
|
||||
};
|
||||
|
||||
const handleOrganisationEmailContext = async (organisationId: string) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
organisationGlobalSettings: true,
|
||||
emailDomains: {
|
||||
omit: {
|
||||
privateKey: true,
|
||||
},
|
||||
include: {
|
||||
emails: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const claims = organisation.organisationClaim;
|
||||
|
||||
const allowedEmails = getAllowedEmails(organisation);
|
||||
|
||||
return {
|
||||
allowedEmails,
|
||||
branding: organisationGlobalSettingsToBranding(
|
||||
organisation.organisationGlobalSettings,
|
||||
organisation.id,
|
||||
claims.flags.hidePoweredBy ?? false,
|
||||
),
|
||||
settings: organisation.organisationGlobalSettings,
|
||||
claims,
|
||||
organisationType: organisation.type,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTeamEmailContext = async (teamId: number) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
organisation: {
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
organisationGlobalSettings: true,
|
||||
emailDomains: {
|
||||
omit: {
|
||||
privateKey: true,
|
||||
},
|
||||
include: {
|
||||
emails: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const organisation = team.organisation;
|
||||
const claims = organisation.organisationClaim;
|
||||
|
||||
const allowedEmails = getAllowedEmails(organisation);
|
||||
|
||||
const teamSettings = extractDerivedTeamSettings(
|
||||
organisation.organisationGlobalSettings,
|
||||
team.teamGlobalSettings,
|
||||
);
|
||||
|
||||
return {
|
||||
allowedEmails,
|
||||
branding: teamGlobalSettingsToBranding(
|
||||
teamSettings,
|
||||
teamId,
|
||||
claims.flags.hidePoweredBy ?? false,
|
||||
),
|
||||
settings: teamSettings,
|
||||
claims,
|
||||
organisationType: organisation.type,
|
||||
};
|
||||
};
|
||||
|
||||
const getAllowedEmails = (
|
||||
organisation: Organisation & {
|
||||
emailDomains: (Pick<EmailDomain, 'status'> & { emails: OrganisationEmail[] })[];
|
||||
organisationClaim: OrganisationClaim;
|
||||
},
|
||||
) => {
|
||||
if (!organisation.organisationClaim.flags.emailDomains) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return organisation.emailDomains
|
||||
.filter((emailDomain) => emailDomain.status === EmailDomainStatus.ACTIVE)
|
||||
.flatMap((emailDomain) => emailDomain.emails);
|
||||
};
|
||||
@ -27,7 +27,6 @@ export const createEmbeddingPresignToken = async ({
|
||||
// In development mode, allow setting expiresIn to 0 for testing
|
||||
// In production, enforce a minimum expiration time
|
||||
const isDevelopment = env('NODE_ENV') !== 'production';
|
||||
console.log('isDevelopment', isDevelopment);
|
||||
const minExpirationMinutes = isDevelopment ? 0 : 5;
|
||||
|
||||
// Ensure expiresIn is at least the minimum allowed value
|
||||
|
||||
@ -6,10 +6,11 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export interface CreateDocumentFieldsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
fields: (TFieldAndMeta & {
|
||||
recipientId: number;
|
||||
@ -29,25 +30,14 @@ export const createDocumentFields = async ({
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: CreateDocumentFieldsOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FieldType, Team } from '@prisma/client';
|
||||
import type { FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -13,11 +13,12 @@ import {
|
||||
import type { TFieldMetaSchema as FieldMeta } from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export type CreateFieldOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
recipientId: number;
|
||||
type: FieldType;
|
||||
pageNumber: number;
|
||||
@ -43,60 +44,23 @@ export const createField = async ({
|
||||
fieldMeta,
|
||||
requestMetadata,
|
||||
}: CreateFieldOptions) => {
|
||||
const { documentWhereInput, team } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(type);
|
||||
|
||||
if (advancedField && !fieldMeta) {
|
||||
@ -154,9 +118,9 @@ export const createField = async ({
|
||||
type: 'FIELD_CREATED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
email: team?.name ?? user.email,
|
||||
name: team ? '' : user.name,
|
||||
id: team.id,
|
||||
email: team.name,
|
||||
name: '',
|
||||
},
|
||||
data: {
|
||||
fieldId: field.secondaryId,
|
||||
|
||||
@ -5,10 +5,11 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface CreateTemplateFieldsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
fields: {
|
||||
recipientId: number;
|
||||
@ -31,21 +32,7 @@ export const createTemplateFields = async ({
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
|
||||
@ -5,10 +5,11 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export interface DeleteDocumentFieldOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
fieldId: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
@ -39,25 +40,14 @@ export const deleteDocumentField = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
|
||||
@ -4,12 +4,13 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteFieldOptions = {
|
||||
fieldId: number;
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@ -25,21 +26,7 @@ export const deleteField = async ({
|
||||
id: fieldId,
|
||||
document: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface DeleteTemplateFieldOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
fieldId: number;
|
||||
}
|
||||
|
||||
@ -16,21 +17,9 @@ export const deleteTemplateField = async ({
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
template: teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
template: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,53 +1,49 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type GetFieldByIdOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
fieldId: number;
|
||||
documentId?: number;
|
||||
templateId?: number;
|
||||
};
|
||||
|
||||
export const getFieldById = async ({
|
||||
userId,
|
||||
teamId,
|
||||
fieldId,
|
||||
documentId,
|
||||
templateId,
|
||||
}: GetFieldByIdOptions) => {
|
||||
export const getFieldById = async ({ userId, teamId, fieldId }: GetFieldByIdOptions) => {
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
documentId,
|
||||
templateId,
|
||||
},
|
||||
include: {
|
||||
document: {
|
||||
OR:
|
||||
teamId === undefined
|
||||
? [
|
||||
{
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
teamId,
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
template: {
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
const foundTeamId = field?.document?.teamId || field?.template?.teamId;
|
||||
|
||||
if (!field || !foundTeamId || foundTeamId !== teamId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Field not found',
|
||||
});
|
||||
}
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId: foundTeamId,
|
||||
userId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Field not found',
|
||||
});
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface GetFieldsForDocumentOptions {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export type DocumentField = Awaited<ReturnType<typeof getFieldsForDocument>>[number];
|
||||
@ -15,22 +17,10 @@ export const getFieldsForDocument = async ({
|
||||
}: GetFieldsForDocumentOptions) => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
documentId,
|
||||
document: teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
document: {
|
||||
id: documentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
|
||||
@ -26,10 +26,11 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
fields: FieldData[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
@ -42,25 +43,14 @@ export const setFieldsForDocument = async ({
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: SetFieldsForDocumentOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
|
||||
@ -16,9 +16,11 @@ import {
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type SetFieldsForTemplateOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
fields: {
|
||||
id?: number | null;
|
||||
@ -42,21 +44,7 @@ export const setFieldsForTemplate = async ({
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
@ -10,6 +11,7 @@ import { validateTextField } from '@documenso/lib/advanced-fields-validation/val
|
||||
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
@ -205,6 +207,29 @@ export const signFieldWithToken = async ({
|
||||
throw new Error('Typed signatures are not allowed. Please draw your signature');
|
||||
}
|
||||
|
||||
if (field.fieldMeta?.readOnly && !AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) {
|
||||
// !: This is a bit of a hack at the moment, readonly fields with default values
|
||||
// !: should be inserted with their default value on document creation instead of
|
||||
// !: this weird programattic approach. Until that's fixed though this will verify
|
||||
// !: that the programmatic signed value is only that of its default.
|
||||
const isAutomaticSigningValueValid = match(field.fieldMeta)
|
||||
.with({ type: 'text' }, (meta) => customText === meta.text)
|
||||
.with({ type: 'number' }, (meta) => customText === meta.value)
|
||||
.with({ type: 'checkbox' }, (meta) =>
|
||||
isDeepEqual(
|
||||
fromCheckboxValue(customText ?? ''),
|
||||
meta.values?.filter((v) => v.checked).map((v) => v.value) ?? [],
|
||||
),
|
||||
)
|
||||
.with({ type: 'radio' }, (meta) => customText === meta.values?.find((v) => v.checked)?.value)
|
||||
.with({ type: 'dropdown' }, (meta) => customText === meta.defaultValue)
|
||||
.otherwise(() => false);
|
||||
|
||||
if (!isAutomaticSigningValueValid) {
|
||||
throw new Error('Field is read only and only accepts its default value for signing.');
|
||||
}
|
||||
}
|
||||
|
||||
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
|
||||
@ -11,10 +11,11 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export interface UpdateDocumentFieldsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
fields: {
|
||||
id: number;
|
||||
@ -36,25 +37,14 @@ export const updateDocumentFields = async ({
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: UpdateDocumentFieldsOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
|
||||
@ -6,12 +6,13 @@ import { prisma } from '@documenso/prisma';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type UpdateFieldOptions = {
|
||||
fieldId: number;
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
recipientId?: number;
|
||||
type?: FieldType;
|
||||
pageNumber?: number;
|
||||
@ -47,21 +48,7 @@ export const updateField = async ({
|
||||
id: fieldId,
|
||||
document: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -101,14 +88,7 @@ export const updateField = async ({
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -5,10 +5,11 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface UpdateTemplateFieldsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
fields: {
|
||||
id: number;
|
||||
@ -31,21 +32,7 @@ export const updateTemplateFields = async ({
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
|
||||
38
packages/lib/server-only/folder/create-folder.ts
Normal file
38
packages/lib/server-only/folder/create-folder.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { FolderType } from '../../types/folder-type';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
export interface CreateFolderOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const createFolder = async ({
|
||||
userId,
|
||||
teamId,
|
||||
name,
|
||||
parentId,
|
||||
type = FolderType.DOCUMENT,
|
||||
}: CreateFolderOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const settings = await getTeamSettings({ userId, teamId });
|
||||
|
||||
return await prisma.folder.create({
|
||||
data: {
|
||||
name,
|
||||
userId,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
visibility: determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
|
||||
},
|
||||
});
|
||||
};
|
||||
57
packages/lib/server-only/folder/delete-folder.ts
Normal file
57
packages/lib/server-only/folder/delete-folder.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface DeleteFolderOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
folderId: string;
|
||||
}
|
||||
|
||||
export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
documents: true,
|
||||
subfolders: true,
|
||||
templates: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
const hasPermission = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => true)
|
||||
.with(TeamMemberRole.MANAGER, () => folder.visibility !== DocumentVisibility.ADMIN)
|
||||
.with(TeamMemberRole.MEMBER, () => folder.visibility === DocumentVisibility.EVERYONE)
|
||||
.otherwise(() => false);
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to delete this folder',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.folder.delete({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
});
|
||||
};
|
||||
122
packages/lib/server-only/folder/find-folders.ts
Normal file
122
packages/lib/server-only/folder/find-folders.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface FindFoldersOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{ parentId },
|
||||
{
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
...whereClause,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
const foldersWithDetails = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
try {
|
||||
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
where: {
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.template.count({
|
||||
where: {
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
|
||||
...subfolder,
|
||||
subfolders: [],
|
||||
_count: {
|
||||
documents: 0,
|
||||
templates: 0,
|
||||
subfolders: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
...folder,
|
||||
subfolders: subfoldersWithEmptySubfolders,
|
||||
_count: {
|
||||
documents: documentCount,
|
||||
templates: templateCount,
|
||||
subfolders: subfolderCount,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing folder:', folder.id, error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return foldersWithDetails;
|
||||
} catch (error) {
|
||||
console.error('Error in findFolders:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
79
packages/lib/server-only/folder/get-folder-breadcrumbs.ts
Normal file
79
packages/lib/server-only/folder/get-folder-breadcrumbs.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface GetFolderBreadcrumbsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const getFolderBreadcrumbs = async ({
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
type,
|
||||
}: GetFolderBreadcrumbsOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
||||
|
||||
const whereClause = (folderId: string) => ({
|
||||
id: folderId,
|
||||
...(type ? { type } : {}),
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
});
|
||||
|
||||
const breadcrumbs = [];
|
||||
let currentFolderId = folderId;
|
||||
|
||||
const currentFolder = await prisma.folder.findFirst({
|
||||
where: whereClause(currentFolderId),
|
||||
});
|
||||
|
||||
if (!currentFolder) {
|
||||
return [];
|
||||
}
|
||||
|
||||
breadcrumbs.push(currentFolder);
|
||||
|
||||
while (currentFolder?.parentId) {
|
||||
const parentFolder = await prisma.folder.findFirst({
|
||||
where: whereClause(currentFolder.parentId),
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
break;
|
||||
}
|
||||
|
||||
breadcrumbs.unshift(parentFolder);
|
||||
currentFolderId = parentFolder.id;
|
||||
currentFolder.parentId = parentFolder.parentId;
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
58
packages/lib/server-only/folder/get-folder-by-id.ts
Normal file
58
packages/lib/server-only/folder/get-folder-by-id.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface GetFolderByIdOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
folderId?: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
||||
|
||||
const whereClause = {
|
||||
id: folderId,
|
||||
...(type ? { type } : {}),
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
};
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
return folder;
|
||||
};
|
||||
92
packages/lib/server-only/folder/move-document-to-folder.ts
Normal file
92
packages/lib/server-only/folder/move-document-to-folder.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface MoveDocumentToFolderOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
folderId?: string | null;
|
||||
requestMetadata?: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const moveDocumentToFolder = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
folderId,
|
||||
}: MoveDocumentToFolderOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
||||
|
||||
const documentWhereClause = {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
};
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereClause,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folderWhereClause = {
|
||||
id: folderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
};
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: folderWhereClause,
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
};
|
||||
89
packages/lib/server-only/folder/move-folder.ts
Normal file
89
packages/lib/server-only/folder/move-folder.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface MoveFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId?: string;
|
||||
parentId?: string | null;
|
||||
requestMetadata?: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const folder = await tx.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parentFolder = await tx.folder.findFirst({
|
||||
where: {
|
||||
id: parentId,
|
||||
userId,
|
||||
teamId,
|
||||
type: folder.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into itself',
|
||||
});
|
||||
}
|
||||
|
||||
let currentParentId = parentFolder.parentId;
|
||||
while (currentParentId) {
|
||||
if (currentParentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into its descendant',
|
||||
});
|
||||
}
|
||||
|
||||
const currentParent = await tx.folder.findUnique({
|
||||
where: {
|
||||
id: currentParentId,
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentParent) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentParentId = currentParent.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
return await tx.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
63
packages/lib/server-only/folder/move-template-to-folder.ts
Normal file
63
packages/lib/server-only/folder/move-template-to-folder.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface MoveTemplateToFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
templateId: number;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
||||
export const moveTemplateToFolder = async ({
|
||||
userId,
|
||||
teamId,
|
||||
templateId,
|
||||
folderId,
|
||||
}: MoveTemplateToFolderOptions) => {
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Template not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (folderId !== null) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.template.update({
|
||||
where: {
|
||||
id: templateId,
|
||||
},
|
||||
data: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
};
|
||||
40
packages/lib/server-only/folder/pin-folder.ts
Normal file
40
packages/lib/server-only/folder/pin-folder.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface PinFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
pinned: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
40
packages/lib/server-only/folder/unpin-folder.ts
Normal file
40
packages/lib/server-only/folder/unpin-folder.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface UnpinFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
pinned: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
56
packages/lib/server-only/folder/update-folder.ts
Normal file
56
packages/lib/server-only/folder/update-folder.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { FolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface UpdateFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
name: string;
|
||||
visibility: DocumentVisibility;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const updateFolder = async ({
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
name,
|
||||
visibility,
|
||||
type,
|
||||
}: UpdateFolderOptions) => {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
|
||||
const effectiveVisibility =
|
||||
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
visibility: effectiveVisibility,
|
||||
},
|
||||
});
|
||||
};
|
||||
83
packages/lib/server-only/htmltopdf/get-audit-logs-pdf.ts
Normal file
83
packages/lib/server-only/htmltopdf/get-audit-logs-pdf.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||
import { env } from '../../utils/env';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
|
||||
export type GetAuditLogsPdfOptions = {
|
||||
documentId: number;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
language?: SupportedLanguageCodes | (string & {});
|
||||
};
|
||||
|
||||
export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfOptions) => {
|
||||
const { chromium } = await import('playwright');
|
||||
|
||||
const encryptedId = encryptSecondaryData({
|
||||
data: documentId.toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
});
|
||||
|
||||
let browser: Browser;
|
||||
|
||||
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
|
||||
|
||||
if (browserlessUrl) {
|
||||
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
|
||||
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
|
||||
browser = await chromium.connectOverCDP(browserlessUrl);
|
||||
} else {
|
||||
browser = await chromium.launch();
|
||||
}
|
||||
|
||||
if (!browser) {
|
||||
throw new Error(
|
||||
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
|
||||
);
|
||||
}
|
||||
|
||||
const browserContext = await browser.newContext();
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
|
||||
const lang = isValidLanguageCode(language) ? language : 'en';
|
||||
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'language',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// !: This is a workaround to ensure the page is loaded correctly.
|
||||
// !: It's not clear why but suddenly browserless cdp connections would
|
||||
// !: cause the page to render blank until a reload is performed.
|
||||
await page.reload({
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await page.waitForSelector('h1', {
|
||||
state: 'visible',
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const result = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
});
|
||||
|
||||
await browserContext.close();
|
||||
|
||||
void browser.close();
|
||||
|
||||
return result;
|
||||
};
|
||||
@ -46,7 +46,7 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
|
||||
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'language',
|
||||
name: 'lang',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
},
|
||||
@ -57,8 +57,22 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// !: This is a workaround to ensure the page is loaded correctly.
|
||||
// !: It's not clear why but suddenly browserless cdp connections would
|
||||
// !: cause the page to render blank until a reload is performed.
|
||||
await page.reload({
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await page.waitForSelector('h1', {
|
||||
state: 'visible',
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const result = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
});
|
||||
|
||||
await browserContext.close();
|
||||
|
||||
@ -0,0 +1,105 @@
|
||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { generateDatabaseId } from '../../universal/id';
|
||||
|
||||
export type AcceptOrganisationInvitationOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const acceptOrganisationInvitation = async ({
|
||||
token,
|
||||
}: AcceptOrganisationInvitationOptions) => {
|
||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findFirst({
|
||||
where: {
|
||||
token,
|
||||
status: {
|
||||
not: OrganisationMemberInviteStatus.DECLINED,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
groups: {
|
||||
include: {
|
||||
teamGroups: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationMemberInvite) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: organisationMemberInvite.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User must exist to accept an organisation invitation',
|
||||
});
|
||||
}
|
||||
|
||||
const { organisation } = organisationMemberInvite;
|
||||
|
||||
const organisationGroupToUse = organisation.groups.find(
|
||||
(group) =>
|
||||
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
|
||||
group.organisationRole === organisationMemberInvite.organisationRole,
|
||||
);
|
||||
|
||||
if (!organisationGroupToUse) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Organisation group not found',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member'),
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
groupId: organisationGroupToUse.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId: organisation.id,
|
||||
memberUserId: user.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,221 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Organisation, Prisma } from '@prisma/client';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { generateDatabaseId } from '../../universal/id';
|
||||
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
|
||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getMemberOrganisationRole } from '../team/get-member-roles';
|
||||
|
||||
export type CreateOrganisationMemberInvitesOptions = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
organisationId: string;
|
||||
invitations: TCreateOrganisationMemberInvitesRequestSchema['invitations'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Invite organisation members via email to join a organisation.
|
||||
*/
|
||||
export const createOrganisationMemberInvites = async ({
|
||||
userId,
|
||||
userName,
|
||||
organisationId,
|
||||
invitations,
|
||||
}: CreateOrganisationMemberInvitesOptions): Promise<void> => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: {
|
||||
where: {
|
||||
status: OrganisationMemberInviteStatus.PENDING,
|
||||
},
|
||||
},
|
||||
organisationGlobalSettings: true,
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const { organisationClaim } = organisation;
|
||||
|
||||
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
|
||||
|
||||
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
||||
organisationId: organisation.id,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const organisationMemberEmails = organisation.members.map((member) => member.user.email);
|
||||
const organisationMemberInviteEmails = organisation.invites.map((invite) => invite.email);
|
||||
|
||||
const usersToInvite = invitations.filter((invitation) => {
|
||||
// Filter out users that are already members of the organisation.
|
||||
if (organisationMemberEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out users that have already been invited to the organisation.
|
||||
if (organisationMemberInviteEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const unauthorizedRoleAccess = usersToInvite.some(
|
||||
({ organisationRole }) =>
|
||||
!isOrganisationRoleWithinUserHierarchy(currentOrganisationMemberRole, organisationRole),
|
||||
);
|
||||
|
||||
if (unauthorizedRoleAccess) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User does not have permission to set high level roles',
|
||||
});
|
||||
}
|
||||
|
||||
const organisationMemberInvites: Prisma.OrganisationMemberInviteCreateManyInput[] =
|
||||
usersToInvite.map(({ email, organisationRole }) => ({
|
||||
id: generateDatabaseId('member_invite'),
|
||||
email,
|
||||
organisationId,
|
||||
organisationRole,
|
||||
token: nanoid(32),
|
||||
}));
|
||||
|
||||
const numberOfCurrentMembers = organisation.members.length;
|
||||
const numberOfCurrentInvites = organisation.invites.length;
|
||||
const numberOfNewInvites = organisationMemberInvites.length;
|
||||
|
||||
const totalMemberCountWithInvites =
|
||||
numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
|
||||
|
||||
// Handle billing for seat based plans.
|
||||
if (subscription) {
|
||||
await syncMemberCountWithStripeSeatPlan(
|
||||
subscription,
|
||||
organisationClaim,
|
||||
totalMemberCountWithInvites,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.organisationMemberInvite.createMany({
|
||||
data: organisationMemberInvites,
|
||||
});
|
||||
|
||||
const sendEmailResult = await Promise.allSettled(
|
||||
organisationMemberInvites.map(async ({ email, token }) =>
|
||||
sendOrganisationMemberInviteEmail({
|
||||
email,
|
||||
token,
|
||||
organisation,
|
||||
senderName: userName,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sendEmailResultErrorList = sendEmailResult.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (sendEmailResultErrorList.length > 0) {
|
||||
console.error(JSON.stringify(sendEmailResultErrorList));
|
||||
|
||||
throw new AppError('EmailDeliveryFailed', {
|
||||
message: 'Failed to send invite emails to one or more users.',
|
||||
userMessage: `Failed to send invites to ${sendEmailResultErrorList.length}/${organisationMemberInvites.length} users.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type SendOrganisationMemberInviteEmailOptions = {
|
||||
email: string;
|
||||
senderName: string;
|
||||
token: string;
|
||||
organisation: Pick<Organisation, 'id' | 'name'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to a user inviting them to join a organisation.
|
||||
*/
|
||||
export const sendOrganisationMemberInviteEmail = async ({
|
||||
email,
|
||||
senderName,
|
||||
token,
|
||||
organisation,
|
||||
}: SendOrganisationMemberInviteEmailOptions) => {
|
||||
const template = createElement(OrganisationInviteEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
senderName,
|
||||
token,
|
||||
organisationName: organisation.name,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
204
packages/lib/server-only/organisation/create-organisation.ts
Normal file
204
packages/lib/server-only/organisation/create-organisation.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
|
||||
import { AppErrorCode } from '../../errors/app-error';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { InternalClaim } from '../../types/subscription';
|
||||
import { INTERNAL_CLAIM_ID, internalClaims } from '../../types/subscription';
|
||||
import { generateDatabaseId, prefixedId } from '../../universal/id';
|
||||
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
|
||||
import { createTeam } from '../team/create-team';
|
||||
|
||||
type CreateOrganisationOptions = {
|
||||
userId: number;
|
||||
name: string;
|
||||
type: OrganisationType;
|
||||
url?: string;
|
||||
customerId?: string;
|
||||
claim: InternalClaim;
|
||||
};
|
||||
|
||||
export const createOrganisation = async ({
|
||||
name,
|
||||
url,
|
||||
type,
|
||||
userId,
|
||||
customerId,
|
||||
claim,
|
||||
}: CreateOrganisationOptions) => {
|
||||
let customerIdToUse = customerId;
|
||||
|
||||
if (!customerId && IS_BILLING_ENABLED()) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
customerIdToUse = await createCustomer({
|
||||
name: user.name || user.email,
|
||||
email: user.email,
|
||||
})
|
||||
.then((customer) => customer.id)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const organisationSetting = await tx.organisationGlobalSettings.create({
|
||||
data: {
|
||||
...generateDefaultOrganisationSettings(),
|
||||
id: generateDatabaseId('org_setting'),
|
||||
},
|
||||
});
|
||||
|
||||
const organisationClaim = await tx.organisationClaim.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_claim'),
|
||||
originalSubscriptionClaimId: claim.id,
|
||||
...createOrganisationClaimUpsertData(claim),
|
||||
},
|
||||
});
|
||||
|
||||
const orgIdAndUrl = prefixedId('org');
|
||||
|
||||
const organisation = await tx.organisation
|
||||
.create({
|
||||
data: {
|
||||
id: orgIdAndUrl,
|
||||
name,
|
||||
type,
|
||||
url: url || orgIdAndUrl,
|
||||
ownerUserId: userId,
|
||||
organisationGlobalSettingsId: organisationSetting.id,
|
||||
organisationClaimId: organisationClaim.id,
|
||||
groups: {
|
||||
create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({
|
||||
...group,
|
||||
id: generateDatabaseId('org_group'),
|
||||
})),
|
||||
},
|
||||
customerId: customerIdToUse,
|
||||
},
|
||||
include: {
|
||||
groups: true,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code === 'P2002') {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Organisation URL already exists',
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
|
||||
const adminGroup = organisation.groups.find(
|
||||
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
|
||||
);
|
||||
|
||||
if (!adminGroup) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Admin group not found',
|
||||
});
|
||||
}
|
||||
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member'),
|
||||
userId,
|
||||
organisationId: organisation.id,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
groupId: adminGroup.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organisation;
|
||||
});
|
||||
};
|
||||
|
||||
type CreatePersonalOrganisationOptions = {
|
||||
userId: number;
|
||||
orgUrl?: string;
|
||||
throwErrorOnOrganisationCreationFailure?: boolean;
|
||||
inheritMembers?: boolean;
|
||||
type?: OrganisationType;
|
||||
};
|
||||
|
||||
export const createPersonalOrganisation = async ({
|
||||
userId,
|
||||
orgUrl,
|
||||
throwErrorOnOrganisationCreationFailure = false,
|
||||
inheritMembers = true,
|
||||
type = OrganisationType.PERSONAL,
|
||||
}: CreatePersonalOrganisationOptions) => {
|
||||
const organisation = await createOrganisation({
|
||||
name: 'Personal Organisation',
|
||||
userId,
|
||||
url: orgUrl,
|
||||
type,
|
||||
claim: internalClaims[INTERNAL_CLAIM_ID.FREE],
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
if (throwErrorOnOrganisationCreationFailure) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Todo: (LOGS)
|
||||
});
|
||||
|
||||
if (organisation) {
|
||||
await createTeam({
|
||||
userId,
|
||||
teamName: 'Personal Team',
|
||||
teamUrl: prefixedId('personal'),
|
||||
organisationId: organisation.id,
|
||||
inheritMembers,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
// Todo: (LOGS)
|
||||
});
|
||||
}
|
||||
|
||||
return organisation;
|
||||
};
|
||||
|
||||
export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalClaim) => {
|
||||
// Done like this to ensure type errors are thrown if items are added.
|
||||
const data: Omit<
|
||||
Prisma.SubscriptionClaimCreateInput,
|
||||
'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'
|
||||
> = {
|
||||
flags: {
|
||||
...subscriptionClaim.flags,
|
||||
},
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
};
|
||||
|
||||
return {
|
||||
...data,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export const getOrganisationClaim = async ({ organisationId }: { organisationId: string }) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirst({
|
||||
where: {
|
||||
organisation: {
|
||||
id: organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationClaim) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
return organisationClaim;
|
||||
};
|
||||
|
||||
export const getOrganisationClaimByTeamId = async ({ teamId }: { teamId: number }) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirst({
|
||||
where: {
|
||||
organisation: {
|
||||
teams: {
|
||||
some: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationClaim) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
return organisationClaim;
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
|
||||
import {
|
||||
PDFCheckBox,
|
||||
@ -13,6 +14,8 @@ import {
|
||||
translate,
|
||||
} from 'pdf-lib';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export const removeOptionalContentGroups = (document: PDFDocument) => {
|
||||
const context = document.context;
|
||||
const catalog = context.lookup(context.trailerInfo.Root);
|
||||
@ -21,12 +24,20 @@ export const removeOptionalContentGroups = (document: PDFDocument) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const flattenForm = (document: PDFDocument) => {
|
||||
export const flattenForm = async (document: PDFDocument) => {
|
||||
removeOptionalContentGroups(document);
|
||||
|
||||
const form = document.getForm();
|
||||
|
||||
form.updateFieldAppearances();
|
||||
const fontNoto = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||
async (res) => res.arrayBuffer(),
|
||||
);
|
||||
|
||||
document.registerFontkit(fontkit);
|
||||
|
||||
const font = await document.embedFont(fontNoto);
|
||||
|
||||
form.updateFieldAppearances(font);
|
||||
|
||||
for (const field of form.getFields()) {
|
||||
for (const widget of field.acroField.getWidgets()) {
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
|
||||
import type { PDFDocument, PDFFont, PDFTextField } from 'pdf-lib';
|
||||
import {
|
||||
RotationTypes,
|
||||
TextAlignment,
|
||||
degrees,
|
||||
radiansToDegrees,
|
||||
rgb,
|
||||
setFontAndSize,
|
||||
} from 'pdf-lib';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@ -34,6 +41,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
|
||||
/**
|
||||
* Red box is the original field width, height and position.
|
||||
*
|
||||
* Blue box is the adjusted field width, height and position. It will represent
|
||||
* where the text will overflow into.
|
||||
*/
|
||||
const isDebugMode =
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
|
||||
@ -226,30 +240,79 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
}));
|
||||
|
||||
const selected: string[] = fromCheckboxValue(field.customText);
|
||||
const direction = meta.data.direction ?? 'vertical';
|
||||
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const offsetY = index * 16;
|
||||
const topPadding = 12;
|
||||
const leftCheckboxPadding = 8;
|
||||
const leftCheckboxLabelPadding = 12;
|
||||
const checkboxSpaceY = 13;
|
||||
|
||||
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
|
||||
if (direction === 'horizontal') {
|
||||
// Horizontal layout: arrange checkboxes side by side with wrapping
|
||||
let currentX = leftCheckboxPadding;
|
||||
let currentY = topPadding;
|
||||
const maxWidth = pageWidth - fieldX - leftCheckboxPadding * 2;
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
checkbox.check();
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
checkbox.check();
|
||||
}
|
||||
|
||||
const labelText = item.value.includes('empty-value-') ? '' : item.value;
|
||||
const labelWidth = font.widthOfTextAtSize(labelText, 12);
|
||||
const itemWidth = leftCheckboxLabelPadding + labelWidth + 16; // checkbox + padding + label + margin
|
||||
|
||||
// Check if item fits on current line, if not wrap to next line
|
||||
if (currentX + itemWidth > maxWidth && index > 0) {
|
||||
currentX = leftCheckboxPadding;
|
||||
currentY += checkboxSpaceY;
|
||||
}
|
||||
|
||||
page.drawText(labelText, {
|
||||
x: fieldX + currentX + leftCheckboxLabelPadding,
|
||||
y: pageHeight - (fieldY + currentY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
checkbox.addToPage(page, {
|
||||
x: fieldX + currentX,
|
||||
y: pageHeight - (fieldY + currentY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
|
||||
currentX += itemWidth;
|
||||
}
|
||||
} else {
|
||||
// Vertical layout: original behavior
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const offsetY = index * checkboxSpaceY + topPadding;
|
||||
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + 16,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
|
||||
|
||||
checkbox.addToPage(page, {
|
||||
x: fieldX,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
if (selected.includes(item.value)) {
|
||||
checkbox.check();
|
||||
}
|
||||
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
checkbox.addToPage(page, {
|
||||
x: fieldX + leftCheckboxPadding,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.with({ type: FieldType.RADIO }, (field) => {
|
||||
@ -268,21 +331,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
|
||||
const selected = field.customText.split(',');
|
||||
|
||||
const topPadding = 12;
|
||||
const leftRadioPadding = 8;
|
||||
const leftRadioLabelPadding = 12;
|
||||
const radioSpaceY = 13;
|
||||
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const offsetY = index * 16;
|
||||
const offsetY = index * radioSpaceY + topPadding;
|
||||
|
||||
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
|
||||
|
||||
// Draw label.
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + 16,
|
||||
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
// Draw radio button.
|
||||
radio.addOptionToPage(item.value, page, {
|
||||
x: fieldX,
|
||||
x: fieldX + leftRadioPadding,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
@ -304,61 +374,145 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
|
||||
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
|
||||
const fieldMetaParser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
|
||||
const meta = fieldMetaParser ? fieldMetaParser.safeParse(field.fieldMeta) : null;
|
||||
|
||||
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
|
||||
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'left';
|
||||
|
||||
let fontSize = customFontSize || maxFontSize;
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
const textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
// Scale font only if no custom font and height exceeds field height.
|
||||
if (!customFontSize) {
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
const scalingFactor = Math.min(fieldHeight / textHeight, 1);
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
}
|
||||
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
/**
|
||||
* Calculate whether the field should be multiline.
|
||||
*
|
||||
* - True = text will overflow downwards.
|
||||
* - False = text will overflow sideways.
|
||||
*/
|
||||
const isMultiline =
|
||||
field.type === FieldType.TEXT &&
|
||||
(textWidth > fieldWidth || field.customText.includes('\n'));
|
||||
|
||||
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
|
||||
const padding = 8; // PDF points, roughly equivalent to 0.5rem
|
||||
const padding = 8;
|
||||
|
||||
// Calculate X position based on text alignment with padding
|
||||
let textX = fieldX + padding; // Left alignment starts after padding
|
||||
if (textAlign === 'center') {
|
||||
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
|
||||
} else if (textAlign === 'right') {
|
||||
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
|
||||
}
|
||||
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
const textAlignmentOptions = getTextAlignmentOptions(textAlign, fieldX, isMultiline, padding);
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
textY = pageHeight - textY - textHeight;
|
||||
let textFieldBoxY = pageHeight - fieldY - fieldHeight;
|
||||
const textFieldBoxX = textAlignmentOptions.xPos;
|
||||
|
||||
const textField = pdf.getForm().createTextField(`text.${field.secondaryId}`);
|
||||
textField.setAlignment(textAlignmentOptions.textAlignment);
|
||||
|
||||
/**
|
||||
* From now on we will adjust the field size and position so the text
|
||||
* overflows correctly in the X or Y axis depending on the field type.
|
||||
*/
|
||||
let adjustedFieldWidth = fieldWidth - padding * 2; //
|
||||
let adjustedFieldHeight = fieldHeight;
|
||||
let adjustedFieldX = textFieldBoxX;
|
||||
let adjustedFieldY = textFieldBoxY;
|
||||
|
||||
let textToInsert = field.customText;
|
||||
|
||||
// The padding to use when fields go off the page.
|
||||
const pagePadding = 4;
|
||||
|
||||
// Handle multiline text, which will overflow on the Y axis.
|
||||
if (isMultiline) {
|
||||
textToInsert = breakLongString(textToInsert, adjustedFieldWidth, font, fontSize);
|
||||
|
||||
textField.enableMultiline();
|
||||
textField.disableCombing();
|
||||
textField.disableScrolling();
|
||||
|
||||
// Adjust the textFieldBox so it extends to the bottom of the page so text can wrap.
|
||||
textFieldBoxY = pageHeight - fieldY - fieldHeight;
|
||||
|
||||
// Calculate how much PX from the current field to bottom of the page.
|
||||
const fieldYOffset = pageHeight - (fieldY + fieldHeight) - pagePadding;
|
||||
|
||||
// Field height will be from current to bottom of page.
|
||||
adjustedFieldHeight = fieldHeight + fieldYOffset;
|
||||
|
||||
// Need to move the field Y so it offsets the new field height.
|
||||
adjustedFieldY = adjustedFieldY - fieldYOffset;
|
||||
}
|
||||
|
||||
// Handle non-multiline text, which will overflow on the X axis.
|
||||
if (!isMultiline) {
|
||||
// Left align will extend all the way to the right of the page
|
||||
if (textAlignmentOptions.textAlignment === TextAlignment.Left) {
|
||||
adjustedFieldWidth = pageWidth - textFieldBoxX - pagePadding;
|
||||
}
|
||||
|
||||
// Right align will extend all the way to the left of the page.
|
||||
if (textAlignmentOptions.textAlignment === TextAlignment.Right) {
|
||||
adjustedFieldWidth = textFieldBoxX + fieldWidth - pagePadding;
|
||||
adjustedFieldX = adjustedFieldX - adjustedFieldWidth + fieldWidth;
|
||||
}
|
||||
|
||||
// Center align will extend to the closest page edge, then use that * 2 as the width.
|
||||
if (textAlignmentOptions.textAlignment === TextAlignment.Center) {
|
||||
const fieldMidpoint = textFieldBoxX + fieldWidth / 2;
|
||||
|
||||
const isCloserToLeftEdge = fieldMidpoint < pageWidth / 2;
|
||||
|
||||
// If field is closer to left edge, the width must be based of the left.
|
||||
if (isCloserToLeftEdge) {
|
||||
adjustedFieldWidth = (textFieldBoxX - pagePadding) * 2 + fieldWidth;
|
||||
adjustedFieldX = pagePadding;
|
||||
}
|
||||
|
||||
// If field is closer to right edge, the width must be based of the right
|
||||
if (!isCloserToLeftEdge) {
|
||||
adjustedFieldWidth = (pageWidth - textFieldBoxX - pagePadding - fieldWidth / 2) * 2;
|
||||
adjustedFieldX = pageWidth - adjustedFieldWidth - pagePadding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
textX,
|
||||
textY,
|
||||
adjustedFieldX,
|
||||
adjustedFieldY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
textX = adjustedPosition.xPos;
|
||||
textY = adjustedPosition.yPos;
|
||||
adjustedFieldX = adjustedPosition.xPos;
|
||||
adjustedFieldY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawText(field.customText, {
|
||||
x: textX,
|
||||
y: textY,
|
||||
size: fontSize,
|
||||
font,
|
||||
// Set properties for the text field
|
||||
setTextFieldFontSize(textField, font, fontSize);
|
||||
textField.setText(textToInsert);
|
||||
|
||||
// Set the position and size of the text field
|
||||
textField.addToPage(page, {
|
||||
x: adjustedFieldX,
|
||||
y: adjustedFieldY,
|
||||
width: adjustedFieldWidth,
|
||||
height: adjustedFieldHeight,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
|
||||
font,
|
||||
|
||||
// Hide borders.
|
||||
borderWidth: 0,
|
||||
borderColor: undefined,
|
||||
backgroundColor: undefined,
|
||||
|
||||
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
@ -393,3 +547,156 @@ const adjustPositionForRotation = (
|
||||
yPos,
|
||||
};
|
||||
};
|
||||
|
||||
const textAlignmentMap = {
|
||||
left: TextAlignment.Left,
|
||||
center: TextAlignment.Center,
|
||||
right: TextAlignment.Right,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get the PDF-lib alignment position, and the X position of the field with padding included.
|
||||
*
|
||||
* @param textAlign - The text alignment of the field.
|
||||
* @param fieldX - The X position of the field.
|
||||
* @param isMultiline - Whether the field is multiline.
|
||||
* @param padding - The padding of the field. Defaults to 8.
|
||||
*
|
||||
* @returns The X position and text alignment for the field.
|
||||
*/
|
||||
const getTextAlignmentOptions = (
|
||||
textAlign: 'left' | 'center' | 'right',
|
||||
fieldX: number,
|
||||
isMultiline: boolean,
|
||||
padding: number = 8,
|
||||
) => {
|
||||
const textAlignment = textAlignmentMap[textAlign];
|
||||
|
||||
// For multiline, it needs to be centered so we just basic left padding.
|
||||
if (isMultiline) {
|
||||
return {
|
||||
xPos: fieldX + padding,
|
||||
textAlignment,
|
||||
};
|
||||
}
|
||||
|
||||
return match(textAlign)
|
||||
.with('left', () => ({
|
||||
xPos: fieldX + padding,
|
||||
textAlignment,
|
||||
}))
|
||||
.with('center', () => ({
|
||||
xPos: fieldX,
|
||||
textAlignment,
|
||||
}))
|
||||
.with('right', () => ({
|
||||
xPos: fieldX - padding,
|
||||
textAlignment,
|
||||
}))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
/**
|
||||
* Break a long string into multiple lines so it fits within a given width,
|
||||
* using natural word breaking similar to word processors.
|
||||
*
|
||||
* - Keeps words together when possible
|
||||
* - Only breaks words when they're too long to fit on a line
|
||||
* - Handles whitespace intelligently
|
||||
*
|
||||
* @param text - The text to break into lines
|
||||
* @param maxWidth - The maximum width of each line in PX
|
||||
* @param font - The PDF font object
|
||||
* @param fontSize - The font size in points
|
||||
* @returns Object containing the result string and line count
|
||||
*/
|
||||
function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize: number): string {
|
||||
// Handle empty text
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Process each original line separately to preserve newlines
|
||||
for (const paragraph of text.split('\n')) {
|
||||
// If paragraph fits on one line or is empty, add it as-is
|
||||
if (paragraph === '' || font.widthOfTextAtSize(paragraph, fontSize) <= maxWidth) {
|
||||
lines.push(paragraph);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split paragraph into words
|
||||
const words = paragraph.split(' ');
|
||||
let currentLine = '';
|
||||
|
||||
for (const word of words) {
|
||||
// Check if adding word to current line would exceed max width
|
||||
const lineWithWord = currentLine.length === 0 ? word : `${currentLine} ${word}`;
|
||||
|
||||
if (font.widthOfTextAtSize(lineWithWord, fontSize) <= maxWidth) {
|
||||
// Word fits, add it to current line
|
||||
currentLine = lineWithWord;
|
||||
} else {
|
||||
// Word doesn't fit on current line
|
||||
|
||||
// First, save current line if it's not empty
|
||||
if (currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
currentLine = '';
|
||||
}
|
||||
|
||||
// Check if word fits on a line by itself
|
||||
if (font.widthOfTextAtSize(word, fontSize) <= maxWidth) {
|
||||
// Word fits on its own line
|
||||
currentLine = word;
|
||||
} else {
|
||||
// Word is too long, need to break it character by character
|
||||
let charLine = '';
|
||||
|
||||
// Process each character in the word
|
||||
for (const char of word) {
|
||||
const nextCharLine = charLine + char;
|
||||
|
||||
if (font.widthOfTextAtSize(nextCharLine, fontSize) <= maxWidth) {
|
||||
// Character fits, add it
|
||||
charLine = nextCharLine;
|
||||
} else {
|
||||
// Character doesn't fit, push current charLine and start a new one
|
||||
lines.push(charLine);
|
||||
charLine = char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining characters as the current line
|
||||
currentLine = charLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if not empty
|
||||
if (currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const setTextFieldFontSize = (textField: PDFTextField, font: PDFFont, fontSize: number) => {
|
||||
textField.defaultUpdateAppearances(font);
|
||||
textField.updateAppearances(font);
|
||||
|
||||
try {
|
||||
textField.setFontSize(fontSize);
|
||||
} catch (err) {
|
||||
let da = textField.acroField.getDefaultAppearance() ?? '';
|
||||
|
||||
da += `\n ${setFontAndSize(font.name, fontSize)}`;
|
||||
|
||||
textField.acroField.setDefaultAppearance(da);
|
||||
}
|
||||
|
||||
textField.defaultUpdateAppearances(font);
|
||||
textField.updateAppearances(font);
|
||||
};
|
||||
|
||||
395
packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts
Normal file
395
packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts
Normal file
@ -0,0 +1,395 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
DEFAULT_STANDARD_FONT_SIZE,
|
||||
MIN_HANDWRITING_FONT_SIZE,
|
||||
MIN_STANDARD_FONT_SIZE,
|
||||
} from '@documenso/lib/constants/pdf';
|
||||
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
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 {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDateFieldMeta,
|
||||
ZEmailFieldMeta,
|
||||
ZInitialsFieldMeta,
|
||||
ZNameFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
|
||||
export const legacy_insertFieldInPDF = 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()),
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
const isDebugMode =
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
|
||||
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
const pages = pdf.getPages();
|
||||
|
||||
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
|
||||
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
|
||||
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;
|
||||
|
||||
let { width: pageWidth, height: pageHeight } = page.getSize();
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
const fieldWidth = pageWidth * (Number(field.width) / 100);
|
||||
const fieldHeight = pageHeight * (Number(field.height) / 100);
|
||||
|
||||
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
||||
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
||||
|
||||
// Draw debug box if debug mode is enabled
|
||||
if (isDebugMode) {
|
||||
let debugX = fieldX;
|
||||
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
debugX,
|
||||
debugY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
debugX = adjustedPosition.xPos;
|
||||
debugY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawRectangle({
|
||||
x: debugX,
|
||||
y: debugY,
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
borderColor: rgb(1, 0, 0), // Red
|
||||
borderWidth: 1,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
}
|
||||
|
||||
const font = await pdf.embedFont(
|
||||
isSignatureField ? fontCaveat : fontNoto,
|
||||
isSignatureField ? { features: { calt: false } } : undefined,
|
||||
);
|
||||
|
||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||
await pdf.embedFont(fontCaveat);
|
||||
}
|
||||
|
||||
await match(field)
|
||||
.with(
|
||||
{
|
||||
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
|
||||
},
|
||||
async (field) => {
|
||||
if (field.signature?.signatureImageAsBase64) {
|
||||
const image = await pdf.embedPng(field.signature?.signatureImageAsBase64 ?? '');
|
||||
|
||||
let imageWidth = image.width;
|
||||
let imageHeight = image.height;
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||
|
||||
imageWidth = imageWidth * scalingFactor;
|
||||
imageHeight = imageHeight * scalingFactor;
|
||||
|
||||
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
||||
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
imageY = pageHeight - imageY - imageHeight;
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
imageX,
|
||||
imageY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
imageX = adjustedPosition.xPos;
|
||||
imageY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawImage(image, {
|
||||
x: imageX,
|
||||
y: imageY,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
} else {
|
||||
const signatureText = field.signature?.typedSignature ?? '';
|
||||
|
||||
const longestLineInTextForWidth = signatureText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let fontSize = maxFontSize;
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
let textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
textY = pageHeight - textY - textHeight;
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
textX,
|
||||
textY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
textX = adjustedPosition.xPos;
|
||||
textY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawText(signatureText, {
|
||||
x: textX,
|
||||
y: textY,
|
||||
size: fontSize,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.with({ type: FieldType.CHECKBOX }, (field) => {
|
||||
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
|
||||
|
||||
if (!meta.success) {
|
||||
console.error(meta.error);
|
||||
|
||||
throw new Error('Invalid checkbox field meta');
|
||||
}
|
||||
|
||||
const values = meta.data.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
}));
|
||||
|
||||
const selected: string[] = fromCheckboxValue(field.customText);
|
||||
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const offsetY = index * 16;
|
||||
|
||||
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
checkbox.check();
|
||||
}
|
||||
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + 16,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
checkbox.addToPage(page, {
|
||||
x: fieldX,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
}
|
||||
})
|
||||
.with({ type: FieldType.RADIO }, (field) => {
|
||||
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
|
||||
|
||||
if (!meta.success) {
|
||||
console.error(meta.error);
|
||||
|
||||
throw new Error('Invalid radio field meta');
|
||||
}
|
||||
|
||||
const values = meta?.data.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
}));
|
||||
|
||||
const selected = field.customText.split(',');
|
||||
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const offsetY = index * 16;
|
||||
|
||||
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
|
||||
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + 16,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
radio.addOptionToPage(item.value, page, {
|
||||
x: fieldX,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
radio.select(item.value);
|
||||
}
|
||||
}
|
||||
})
|
||||
.otherwise((field) => {
|
||||
const fieldMetaParsers = {
|
||||
[FieldType.TEXT]: ZTextFieldMeta,
|
||||
[FieldType.NUMBER]: ZNumberFieldMeta,
|
||||
[FieldType.DATE]: ZDateFieldMeta,
|
||||
[FieldType.EMAIL]: ZEmailFieldMeta,
|
||||
[FieldType.NAME]: ZNameFieldMeta,
|
||||
[FieldType.INITIALS]: ZInitialsFieldMeta,
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
|
||||
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
|
||||
|
||||
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
|
||||
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let fontSize = customFontSize || maxFontSize;
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
if (!customFontSize) {
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
}
|
||||
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
|
||||
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
|
||||
const padding = 8; // PDF points, roughly equivalent to 0.5rem
|
||||
|
||||
// Calculate X position based on text alignment with padding
|
||||
let textX = fieldX + padding; // Left alignment starts after padding
|
||||
if (textAlign === 'center') {
|
||||
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
|
||||
} else if (textAlign === 'right') {
|
||||
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
|
||||
}
|
||||
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
textY = pageHeight - textY - textHeight;
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
textX,
|
||||
textY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
textX = adjustedPosition.xPos;
|
||||
textY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawText(field.customText, {
|
||||
x: textX,
|
||||
y: textY,
|
||||
size: fontSize,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
});
|
||||
|
||||
return pdf;
|
||||
};
|
||||
|
||||
const adjustPositionForRotation = (
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
xPos: number,
|
||||
yPos: number,
|
||||
pageRotationInDegrees: number,
|
||||
) => {
|
||||
if (pageRotationInDegrees === 270) {
|
||||
xPos = pageWidth - xPos;
|
||||
[xPos, yPos] = [yPos, xPos];
|
||||
}
|
||||
|
||||
if (pageRotationInDegrees === 90) {
|
||||
yPos = pageHeight - yPos;
|
||||
[xPos, yPos] = [yPos, xPos];
|
||||
}
|
||||
|
||||
// Invert all the positions since it's rotated by 180 degrees.
|
||||
if (pageRotationInDegrees === 180) {
|
||||
xPos = pageWidth - xPos;
|
||||
yPos = pageHeight - yPos;
|
||||
}
|
||||
|
||||
return {
|
||||
xPos,
|
||||
yPos,
|
||||
};
|
||||
};
|
||||
@ -11,7 +11,7 @@ export const normalizePdf = async (pdf: Buffer) => {
|
||||
}
|
||||
|
||||
removeOptionalContentGroups(pdfDoc);
|
||||
flattenForm(pdfDoc);
|
||||
await flattenForm(pdfDoc);
|
||||
flattenAnnotations(pdfDoc);
|
||||
|
||||
return Buffer.from(await pdfDoc.save());
|
||||
|
||||
@ -1,15 +1,8 @@
|
||||
import type { Template, TemplateDirectLink } from '@prisma/client';
|
||||
import {
|
||||
SubscriptionStatus,
|
||||
type TeamProfile,
|
||||
TemplateType,
|
||||
type UserProfile,
|
||||
} from '@prisma/client';
|
||||
import { type TeamProfile, TemplateType } from '@prisma/client';
|
||||
|
||||
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type GetPublicProfileByUrlOptions = {
|
||||
@ -23,7 +16,7 @@ type PublicDirectLinkTemplate = Template & {
|
||||
};
|
||||
};
|
||||
|
||||
type BaseResponse = {
|
||||
type GetPublicProfileByUrlResponse = {
|
||||
url: string;
|
||||
name: string;
|
||||
avatarImageId?: string | null;
|
||||
@ -32,155 +25,56 @@ type BaseResponse = {
|
||||
since: Date;
|
||||
};
|
||||
templates: PublicDirectLinkTemplate[];
|
||||
profile: TeamProfile;
|
||||
};
|
||||
|
||||
type GetPublicProfileByUrlResponse = BaseResponse &
|
||||
(
|
||||
| {
|
||||
type: 'User';
|
||||
profile: UserProfile;
|
||||
}
|
||||
| {
|
||||
type: 'Team';
|
||||
profile: TeamProfile;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the user or team public profile by URL.
|
||||
*/
|
||||
export const getPublicProfileByUrl = async ({
|
||||
profileUrl,
|
||||
}: GetPublicProfileByUrlOptions): Promise<GetPublicProfileByUrlResponse> => {
|
||||
const [user, team] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: {
|
||||
url: profileUrl,
|
||||
profile: {
|
||||
enabled: true,
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
url: profileUrl,
|
||||
profile: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
templates: {
|
||||
where: {
|
||||
directLink: {
|
||||
enabled: true,
|
||||
},
|
||||
type: TemplateType.PUBLIC,
|
||||
},
|
||||
include: {
|
||||
directLink: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
templates: {
|
||||
where: {
|
||||
directLink: {
|
||||
enabled: true,
|
||||
},
|
||||
type: TemplateType.PUBLIC,
|
||||
},
|
||||
include: {
|
||||
directLink: true,
|
||||
},
|
||||
},
|
||||
// Subscriptions and teamMembers are used to calculate the badges.
|
||||
subscriptions: {
|
||||
where: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
teamMembers: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.team.findFirst({
|
||||
where: {
|
||||
url: profileUrl,
|
||||
profile: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
templates: {
|
||||
where: {
|
||||
directLink: {
|
||||
enabled: true,
|
||||
},
|
||||
type: TemplateType.PUBLIC,
|
||||
},
|
||||
include: {
|
||||
directLink: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
// Log as critical error.
|
||||
if (user?.profile && team?.profile) {
|
||||
console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id });
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Profile URL is ambiguous',
|
||||
if (!team?.profile?.enabled) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (user?.profile?.enabled) {
|
||||
let badge: BaseResponse['badge'] = undefined;
|
||||
|
||||
if (user.teamMembers[0]) {
|
||||
badge = {
|
||||
type: 'Premium',
|
||||
since: user.teamMembers[0]['createdAt'],
|
||||
};
|
||||
}
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const earlyAdopterPriceIds = await getCommunityPlanPriceIds();
|
||||
|
||||
const activeEarlyAdopterSub = user.subscriptions.find(
|
||||
(subscription) =>
|
||||
subscription.status === SubscriptionStatus.ACTIVE &&
|
||||
earlyAdopterPriceIds.includes(subscription.priceId),
|
||||
);
|
||||
|
||||
if (activeEarlyAdopterSub) {
|
||||
badge = {
|
||||
type: 'EarlySupporter',
|
||||
since: activeEarlyAdopterSub.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'User',
|
||||
badge,
|
||||
profile: user.profile,
|
||||
url: profileUrl,
|
||||
avatarImageId: user.avatarImageId,
|
||||
name: user.name || '',
|
||||
templates: user.templates.filter(
|
||||
(template): template is PublicDirectLinkTemplate =>
|
||||
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (team?.profile?.enabled) {
|
||||
return {
|
||||
type: 'Team',
|
||||
badge: {
|
||||
type: 'Premium',
|
||||
since: team.createdAt,
|
||||
},
|
||||
profile: team.profile,
|
||||
url: profileUrl,
|
||||
avatarImageId: team.avatarImageId,
|
||||
name: team.name || '',
|
||||
templates: team.templates.filter(
|
||||
(template): template is PublicDirectLinkTemplate =>
|
||||
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Profile not found',
|
||||
});
|
||||
return {
|
||||
badge: {
|
||||
type: 'Premium',
|
||||
since: team.createdAt,
|
||||
},
|
||||
profile: team.profile,
|
||||
url: profileUrl,
|
||||
avatarImageId: team.avatarImageId,
|
||||
name: team.name || '',
|
||||
templates: team.templates.filter(
|
||||
(template): template is PublicDirectLinkTemplate =>
|
||||
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@ -2,51 +2,49 @@ import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type SetAvatarImageOptions = {
|
||||
userId: number;
|
||||
teamId?: number | null;
|
||||
target:
|
||||
| {
|
||||
type: 'user';
|
||||
}
|
||||
| {
|
||||
type: 'team';
|
||||
teamId: number;
|
||||
}
|
||||
| {
|
||||
type: 'organisation';
|
||||
organisationId: string;
|
||||
};
|
||||
bytes?: string | null;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pretty nasty but will do for now.
|
||||
*/
|
||||
export const setAvatarImage = async ({
|
||||
userId,
|
||||
teamId,
|
||||
target,
|
||||
bytes,
|
||||
requestMetadata,
|
||||
}: SetAvatarImageOptions) => {
|
||||
let oldAvatarImageId: string | null = null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
avatarImage: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
oldAvatarImageId = user.avatarImageId;
|
||||
|
||||
if (teamId) {
|
||||
if (target.type === 'team') {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: buildTeamWhereQuery({
|
||||
teamId: target.teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
@ -56,6 +54,39 @@ export const setAvatarImage = async ({
|
||||
}
|
||||
|
||||
oldAvatarImageId = team.avatarImageId;
|
||||
} else if (target.type === 'organisation') {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId: target.organisationId,
|
||||
userId,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError('ORGANISATION_NOT_FOUND', {
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
oldAvatarImageId = organisation.avatarImageId;
|
||||
} else {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
avatarImage: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError('USER_NOT_FOUND', {
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
oldAvatarImageId = user.avatarImageId;
|
||||
}
|
||||
|
||||
if (oldAvatarImageId) {
|
||||
@ -83,17 +114,26 @@ export const setAvatarImage = async ({
|
||||
newAvatarImageId = avatarImage.id;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
// TODO: Audit Logs
|
||||
|
||||
if (target.type === 'team') {
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
id: target.teamId,
|
||||
},
|
||||
data: {
|
||||
avatarImageId: newAvatarImageId,
|
||||
},
|
||||
});
|
||||
} else if (target.type === 'organisation') {
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: target.organisationId,
|
||||
},
|
||||
data: {
|
||||
avatarImageId: newAvatarImageId,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Audit Logs
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
@ -103,8 +143,6 @@ export const setAvatarImage = async ({
|
||||
avatarImageId: newAvatarImageId,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Audit Logs
|
||||
}
|
||||
|
||||
return newAvatarImageId;
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type { Duration } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
// temporary choice for testing only
|
||||
import * as timeConstants from '../../constants/time';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { alphaid } from '../../universal/id';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
type TimeConstants = typeof timeConstants & {
|
||||
@ -16,7 +17,7 @@ type TimeConstants = typeof timeConstants & {
|
||||
|
||||
type CreateApiTokenInput = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
tokenName: string;
|
||||
expiresIn: string | null;
|
||||
};
|
||||
@ -33,20 +34,18 @@ export const createApiToken = async ({
|
||||
|
||||
const timeConstantsRecords: TimeConstants = timeConstants;
|
||||
|
||||
if (teamId) {
|
||||
const member = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to create a token for this team',
|
||||
});
|
||||
}
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to create a token for this team',
|
||||
});
|
||||
}
|
||||
|
||||
const storedToken = await prisma.apiToken.create({
|
||||
|
||||
@ -1,32 +1,34 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteTokenByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOptions) => {
|
||||
if (teamId) {
|
||||
const member = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new Error('You do not have permission to delete this token');
|
||||
}
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to delete this token',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.apiToken.delete({
|
||||
where: {
|
||||
id,
|
||||
teamId: teamId ?? null,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetApiTokenByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getApiTokenById = async ({ id, userId }: GetApiTokenByIdOptions) => {
|
||||
return await prisma.apiToken.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -10,8 +10,30 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
token: hashedToken,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
user: true,
|
||||
team: {
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -25,11 +47,7 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
|
||||
// Handle a silly choice from many moons ago
|
||||
if (apiToken.team && !apiToken.user) {
|
||||
apiToken.user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: apiToken.team.ownerUserId,
|
||||
},
|
||||
});
|
||||
apiToken.user = apiToken.team.organisation.owner;
|
||||
}
|
||||
|
||||
const { user } = apiToken;
|
||||
|
||||
@ -1,31 +1,21 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type GetApiTokensOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const getApiTokens = async ({ userId, teamId }: GetApiTokensOptions) => {
|
||||
return await prisma.apiToken.findMany({
|
||||
where: {
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
@ -12,18 +11,19 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export interface CreateDocumentRecipientsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
recipients: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
@ -35,27 +35,25 @@ export const createDocumentRecipients = async ({
|
||||
recipients: recipientsToCreate,
|
||||
requestMetadata,
|
||||
}: CreateDocumentRecipientsOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -71,20 +69,15 @@ export const createDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
|
||||
const recipientsHaveActionAuth = recipientsToCreate.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
@ -110,8 +103,8 @@ export const createDocumentRecipients = async ({
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
const authOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipient.accessAuth || null,
|
||||
actionAuth: recipient.actionAuth || null,
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
});
|
||||
|
||||
const createdRecipient = await tx.recipient.create({
|
||||
@ -140,8 +133,8 @@ export const createDocumentRecipients = async ({
|
||||
recipientName: createdRecipient.name,
|
||||
recipientId: createdRecipient.id,
|
||||
recipientRole: createdRecipient.role,
|
||||
accessAuth: recipient.accessAuth || undefined,
|
||||
actionAuth: recipient.actionAuth || undefined,
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
@ -9,18 +8,19 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface CreateTemplateRecipientsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
recipients: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
}
|
||||
|
||||
@ -33,24 +33,19 @@ export const createTemplateRecipients = async ({
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -60,20 +55,15 @@ export const createTemplateRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
|
||||
const recipientsHaveActionAuth = recipientsToCreate.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
@ -99,8 +89,8 @@ export const createTemplateRecipients = async ({
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
const authOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipient.accessAuth || null,
|
||||
actionAuth: recipient.actionAuth || null,
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
});
|
||||
|
||||
const createdRecipient = await tx.recipient.create({
|
||||
|
||||
@ -11,15 +11,16 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface DeleteDocumentRecipientOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
recipientId: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
@ -37,21 +38,7 @@ export const deleteDocumentRecipient = async ({
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
@ -137,22 +124,29 @@ export const deleteDocumentRecipient = async ({
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipientToDelete.email,
|
||||
name: recipientToDelete.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`You have been removed from a document`),
|
||||
html,
|
||||
text,
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import type { Team } from '@prisma/client';
|
||||
import { SendStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteRecipientOptions = {
|
||||
documentId: number;
|
||||
recipientId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@ -26,21 +27,8 @@ export const deleteRecipient = async ({
|
||||
id: recipientId,
|
||||
document: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
userId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -59,19 +47,12 @@ export const deleteRecipient = async ({
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
});
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const deletedRecipient = await prisma.$transaction(async (tx) => {
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface DeleteTemplateRecipientOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
@ -20,21 +21,7 @@ export const deleteTemplateRecipient = async ({
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type GetRecipientByIdOptions = {
|
||||
recipientId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -20,21 +21,9 @@ export const getRecipientById = async ({
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
document: teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
document: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export interface GetRecipientsForDocumentOptions {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export const getRecipientsForDocument = async ({
|
||||
@ -11,24 +13,15 @@ export const getRecipientsForDocument = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: GetRecipientsForDocumentOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId,
|
||||
document: teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
document: documentWhereInput,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
|
||||
@ -3,7 +3,7 @@ import { prisma } from '@documenso/prisma';
|
||||
export interface GetRecipientsForTemplateOptions {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export const getRecipientsForTemplate = async ({
|
||||
|
||||
@ -4,8 +4,8 @@ import { msg } from '@lingui/core/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
@ -25,16 +25,16 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface SetDocumentRecipientsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
recipients: RecipientData[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
@ -47,31 +47,24 @@ export const setDocumentRecipients = async ({
|
||||
recipients,
|
||||
requestMetadata,
|
||||
}: SetDocumentRecipientsOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
fields: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -96,20 +89,24 @@ export const setDocumentRecipients = async ({
|
||||
throw new Error('Document already complete');
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
});
|
||||
|
||||
const recipientsHaveActionAuth = recipients.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
@ -137,6 +134,9 @@ export const setDocumentRecipients = async ({
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
);
|
||||
|
||||
const canPersistedRecipientBeModified =
|
||||
existing && canRecipientBeModified(existing, document.fields);
|
||||
|
||||
if (
|
||||
existing &&
|
||||
hasRecipientBeenChanged(existing, recipient) &&
|
||||
@ -150,6 +150,7 @@ export const setDocumentRecipients = async ({
|
||||
return {
|
||||
...recipient,
|
||||
_persisted: existing,
|
||||
canPersistedRecipientBeModified,
|
||||
};
|
||||
});
|
||||
|
||||
@ -165,6 +166,13 @@ export const setDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
|
||||
return {
|
||||
...recipient._persisted,
|
||||
clientId: recipient.clientId,
|
||||
};
|
||||
}
|
||||
|
||||
const upsertedRecipient = await tx.recipient.upsert({
|
||||
where: {
|
||||
id: recipient._persisted?.id ?? -1,
|
||||
@ -245,14 +253,17 @@ export const setDocumentRecipients = async ({
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
...baseAuditLog,
|
||||
accessAuth: recipient.accessAuth || undefined,
|
||||
actionAuth: recipient.actionAuth || undefined,
|
||||
accessAuth: recipient.accessAuth || [],
|
||||
actionAuth: recipient.actionAuth || [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedRecipient;
|
||||
return {
|
||||
...upsertedRecipient,
|
||||
clientId: recipient.clientId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -303,26 +314,20 @@ export const setDocumentRecipients = async ({
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
|
||||
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(document.documentMeta?.language);
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`You have been removed from a document`),
|
||||
html,
|
||||
text,
|
||||
@ -332,7 +337,7 @@ export const setDocumentRecipients = async ({
|
||||
}
|
||||
|
||||
// Filter out recipients that have been removed or have been updated.
|
||||
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
|
||||
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
|
||||
const isRemoved = removedRecipients.find(
|
||||
(removedRecipient) => removedRecipient.id === recipient.id,
|
||||
);
|
||||
@ -353,26 +358,31 @@ export const setDocumentRecipients = async ({
|
||||
*/
|
||||
type RecipientData = {
|
||||
id?: number | null;
|
||||
clientId?: string | null;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
};
|
||||
|
||||
type RecipientDataWithClientId = Recipient & {
|
||||
clientId?: string | null;
|
||||
};
|
||||
|
||||
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
|
||||
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||
|
||||
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
|
||||
const newRecipientActionAuth = newRecipientData.actionAuth || null;
|
||||
const newRecipientAccessAuth = newRecipientData.accessAuth || [];
|
||||
const newRecipientActionAuth = newRecipientData.actionAuth || [];
|
||||
|
||||
return (
|
||||
recipient.email !== newRecipientData.email ||
|
||||
recipient.name !== newRecipientData.name ||
|
||||
recipient.role !== newRecipientData.role ||
|
||||
recipient.signingOrder !== newRecipientData.signingOrder ||
|
||||
authOptions.accessAuth !== newRecipientAccessAuth ||
|
||||
authOptions.actionAuth !== newRecipientActionAuth
|
||||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
|
||||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import {
|
||||
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
DIRECT_TEMPLATE_RECIPIENT_NAME,
|
||||
@ -15,10 +14,11 @@ import {
|
||||
} from '../../types/document-auth';
|
||||
import { nanoid } from '../../universal/id';
|
||||
import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type SetTemplateRecipientsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
recipients: {
|
||||
id?: number;
|
||||
@ -26,7 +26,7 @@ export type SetTemplateRecipientsOptions = {
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
};
|
||||
|
||||
@ -39,24 +39,19 @@ export const setTemplateRecipients = async ({
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
include: {
|
||||
directLink: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -64,20 +59,15 @@ export const setTemplateRecipients = async ({
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
const recipientsHaveActionAuth = recipients.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => {
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
@ -19,10 +17,11 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export interface UpdateDocumentRecipientsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
recipients: RecipientData[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
@ -35,28 +34,26 @@ export const updateDocumentRecipients = async ({
|
||||
recipients,
|
||||
requestMetadata,
|
||||
}: UpdateDocumentRecipientsOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
fields: true,
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -72,20 +69,15 @@ export const updateDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
const recipientsHaveActionAuth = recipients.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
@ -110,10 +102,7 @@ export const updateDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hasRecipientBeenChanged(originalRecipient, recipient) &&
|
||||
!canRecipientBeModified(originalRecipient, document.fields)
|
||||
) {
|
||||
if (!canRecipientBeModified(originalRecipient, document.fields)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot modify a recipient who has already interacted with the document',
|
||||
});
|
||||
@ -209,31 +198,12 @@ export const updateDocumentRecipients = async ({
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* If you change this you MUST update the `hasRecipientBeenChanged` function.
|
||||
*/
|
||||
type RecipientData = {
|
||||
id: number;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
};
|
||||
|
||||
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
|
||||
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||
|
||||
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
|
||||
const newRecipientActionAuth = newRecipientData.actionAuth || null;
|
||||
|
||||
return (
|
||||
recipient.email !== newRecipientData.email ||
|
||||
recipient.name !== newRecipientData.name ||
|
||||
recipient.role !== newRecipientData.role ||
|
||||
recipient.signingOrder !== newRecipientData.signingOrder ||
|
||||
authOptions.accessAuth !== newRecipientAccessAuth ||
|
||||
authOptions.actionAuth !== newRecipientActionAuth
|
||||
);
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
};
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
import type { RecipientRole, Team } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import {
|
||||
type TRecipientActionAuthTypes,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '../../types/document-auth';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
|
||||
import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
|
||||
export type UpdateRecipientOptions = {
|
||||
documentId: number;
|
||||
recipientId: number;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const updateRecipient = async ({
|
||||
documentId,
|
||||
recipientId,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
signingOrder,
|
||||
actionAuth,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: UpdateRecipientOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
document: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
document: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
if (actionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||
|
||||
const updatedRecipient = await prisma.$transaction(async (tx) => {
|
||||
const persisted = await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
email: email?.toLowerCase() ?? recipient.email,
|
||||
name: name ?? recipient.name,
|
||||
role: role ?? recipient.role,
|
||||
signingOrder,
|
||||
authOptions: createRecipientAuthOptions({
|
||||
accessAuth: recipientAuthOptions.accessAuth,
|
||||
actionAuth: actionAuth ?? null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const changes = diffRecipientChanges(recipient, persisted);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
documentId: documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
name: team?.name ?? user.name,
|
||||
email: team ? '' : user.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes,
|
||||
recipientId,
|
||||
recipientEmail: persisted.email,
|
||||
recipientName: persisted.name,
|
||||
recipientRole: persisted.role,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return persisted;
|
||||
}
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
};
|
||||
@ -1,7 +1,6 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
type TRecipientActionAuthTypes,
|
||||
@ -11,10 +10,11 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface UpdateTemplateRecipientsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
recipients: {
|
||||
id: number;
|
||||
@ -22,8 +22,8 @@ export interface UpdateTemplateRecipientsOptions {
|
||||
name?: string;
|
||||
role?: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
}
|
||||
|
||||
@ -36,24 +36,19 @@ export const updateTemplateRecipients = async ({
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -63,20 +58,15 @@ export const updateTemplateRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
const recipientsHaveActionAuth = recipients.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetSubscriptionsByUserIdOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => {
|
||||
return await prisma.subscription.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,101 +0,0 @@
|
||||
import { TeamMemberInviteStatus } from '@prisma/client';
|
||||
|
||||
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { jobs } from '../../jobs/client';
|
||||
|
||||
export type AcceptTeamInvitationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
|
||||
where: {
|
||||
teamId,
|
||||
email: user.email,
|
||||
status: {
|
||||
not: TeamMemberInviteStatus.DECLINED,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (teamMemberInvite.status === TeamMemberInviteStatus.ACCEPTED) {
|
||||
const memberExists = await tx.teamMember.findFirst({
|
||||
where: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (memberExists) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { team } = teamMemberInvite;
|
||||
|
||||
const teamMember = await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
userId: user.id,
|
||||
role: teamMemberInvite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.update({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: TeamMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.team-member-joined.email',
|
||||
payload: {
|
||||
teamId: teamMember.teamId,
|
||||
memberId: teamMember.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateTeamBillingPortalOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const createTeamBillingPortal = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: CreateTeamBillingPortalOptions) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new Error('Billing is not enabled');
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_BILLING'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team.subscription) {
|
||||
throw new Error('Team has no subscription');
|
||||
}
|
||||
|
||||
if (!team.customerId) {
|
||||
throw new Error('Team has no customerId');
|
||||
}
|
||||
|
||||
return getPortalSession({
|
||||
customerId: team.customerId,
|
||||
});
|
||||
};
|
||||
@ -1,54 +0,0 @@
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateTeamPendingCheckoutSession = {
|
||||
userId: number;
|
||||
pendingTeamId: number;
|
||||
interval: 'monthly' | 'yearly';
|
||||
};
|
||||
|
||||
export const createTeamPendingCheckoutSession = async ({
|
||||
userId,
|
||||
pendingTeamId,
|
||||
interval,
|
||||
}: CreateTeamPendingCheckoutSession) => {
|
||||
const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
const prices = await getTeamPrices();
|
||||
const priceId = prices[interval].priceId;
|
||||
|
||||
try {
|
||||
const stripeCheckoutSession = await getCheckoutSession({
|
||||
customerId: teamPendingCreation.customerId,
|
||||
priceId,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/teams`,
|
||||
subscriptionMetadata: {
|
||||
pendingTeamId: pendingTeamId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!stripeCheckoutSession) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
return stripeCheckoutSession;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
// Absorb all the errors incase Stripe throws something sensitive.
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Something went wrong.',
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,14 +1,13 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Team, TeamGlobalSettings } from '@prisma/client';
|
||||
import type { Team } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
|
||||
@ -17,7 +16,8 @@ import { prisma } from '@documenso/prisma';
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { env } from '../../utils/env';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export type CreateTeamEmailVerificationOptions = {
|
||||
userId: number;
|
||||
@ -34,33 +34,26 @@ export const createTeamEmailVerification = async ({
|
||||
data,
|
||||
}: CreateTeamEmailVerificationOptions): Promise<void> => {
|
||||
try {
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Team already has an email or existing email verification.',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Team already has an email or existing email verification.',
|
||||
});
|
||||
}
|
||||
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
@ -116,13 +109,7 @@ export const createTeamEmailVerification = async ({
|
||||
* @param teamName The name of the team the user is being invited to.
|
||||
* @param teamUrl The url of the team the user is being invited to.
|
||||
*/
|
||||
export const sendTeamEmailVerificationEmail = async (
|
||||
email: string,
|
||||
token: string,
|
||||
team: Team & {
|
||||
teamGlobalSettings?: TeamGlobalSettings | null;
|
||||
},
|
||||
) => {
|
||||
export const sendTeamEmailVerificationEmail = async (email: string, token: string, team: Team) => {
|
||||
const assetBaseUrl = env('NEXT_PUBLIC_WEBAPP_URL') || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(ConfirmTeamEmailTemplate, {
|
||||
@ -133,29 +120,28 @@ export const sendTeamEmailVerificationEmail = async (
|
||||
token,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const lang = team.teamGlobalSettings?.documentLanguage;
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang,
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(lang);
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
from: senderEmail,
|
||||
subject: i18n._(
|
||||
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
|
||||
),
|
||||
|
||||
@ -1,190 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Team, TeamGlobalSettings } from '@prisma/client';
|
||||
import { TeamMemberInviteStatus } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export type CreateTeamMemberInvitesOptions = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
teamId: number;
|
||||
invitations: TCreateTeamMemberInvitesMutationSchema['invitations'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Invite team members via email to join a team.
|
||||
*/
|
||||
export const createTeamMemberInvites = async ({
|
||||
userId,
|
||||
userName,
|
||||
teamId,
|
||||
invitations,
|
||||
}: CreateTeamMemberInvitesOptions): Promise<void> => {
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
role: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMemberEmails = team.members.map((member) => member.user.email);
|
||||
const teamMemberInviteEmails = team.invites.map((invite) => invite.email);
|
||||
const currentTeamMember = team.members.find((member) => member.user.id === userId);
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User not part of team.',
|
||||
});
|
||||
}
|
||||
|
||||
const usersToInvite = invitations.filter((invitation) => {
|
||||
// Filter out users that are already members of the team.
|
||||
if (teamMemberEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out users that have already been invited to the team.
|
||||
if (teamMemberInviteEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const unauthorizedRoleAccess = usersToInvite.some(
|
||||
({ role }) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, role),
|
||||
);
|
||||
|
||||
if (unauthorizedRoleAccess) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User does not have permission to set high level roles',
|
||||
});
|
||||
}
|
||||
|
||||
const teamMemberInvites = usersToInvite.map(({ email, role }) => ({
|
||||
email,
|
||||
teamId,
|
||||
role,
|
||||
status: TeamMemberInviteStatus.PENDING,
|
||||
token: nanoid(32),
|
||||
}));
|
||||
|
||||
await prisma.teamMemberInvite.createMany({
|
||||
data: teamMemberInvites,
|
||||
});
|
||||
|
||||
const sendEmailResult = await Promise.allSettled(
|
||||
teamMemberInvites.map(async ({ email, token }) =>
|
||||
sendTeamMemberInviteEmail({
|
||||
email,
|
||||
token,
|
||||
team,
|
||||
senderName: userName,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sendEmailResultErrorList = sendEmailResult.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (sendEmailResultErrorList.length > 0) {
|
||||
console.error(JSON.stringify(sendEmailResultErrorList));
|
||||
|
||||
throw new AppError('EmailDeliveryFailed', {
|
||||
message: 'Failed to send invite emails to one or more users.',
|
||||
userMessage: `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type SendTeamMemberInviteEmailOptions = {
|
||||
email: string;
|
||||
senderName: string;
|
||||
token: string;
|
||||
team: Team & {
|
||||
teamGlobalSettings?: TeamGlobalSettings | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to a user inviting them to join a team.
|
||||
*/
|
||||
export const sendTeamMemberInviteEmail = async ({
|
||||
email,
|
||||
senderName,
|
||||
token,
|
||||
team,
|
||||
}: SendTeamMemberInviteEmailOptions) => {
|
||||
const template = createElement(TeamInviteEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
senderName,
|
||||
token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: team.teamGlobalSettings?.documentLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: team.teamGlobalSettings?.documentLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(team.teamGlobalSettings?.documentLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`You have been invited to join ${team.name} on Documenso`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@ -1,17 +1,18 @@
|
||||
import { Prisma, TeamMemberRole } from '@prisma/client';
|
||||
import type Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
|
||||
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
|
||||
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
||||
import { isDocumentPlatform as isUserPlatformPlan } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { stripe } from '../stripe';
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import {
|
||||
LOWEST_ORGANISATION_ROLE,
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
} from '../../constants/organisations';
|
||||
import { TEAM_INTERNAL_GROUPS } from '../../constants/teams';
|
||||
import { generateDatabaseId } from '../../universal/id';
|
||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||
import { generateDefaultTeamSettings } from '../../utils/teams';
|
||||
|
||||
export type CreateTeamOptions = {
|
||||
/**
|
||||
@ -30,264 +31,169 @@ export type CreateTeamOptions = {
|
||||
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
|
||||
*/
|
||||
teamUrl: string;
|
||||
|
||||
/**
|
||||
* ID of the organisation the team belongs to.
|
||||
*/
|
||||
organisationId: string;
|
||||
|
||||
/**
|
||||
* Whether to inherit all members from the organisation.
|
||||
*/
|
||||
inheritMembers: boolean;
|
||||
|
||||
/**
|
||||
* List of additional groups to attach to the team.
|
||||
*/
|
||||
groups?: {
|
||||
id: string;
|
||||
role: TeamMemberRole;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const ZCreateTeamResponseSchema = z.union([
|
||||
z.object({
|
||||
paymentRequired: z.literal(false),
|
||||
}),
|
||||
z.object({
|
||||
paymentRequired: z.literal(true),
|
||||
pendingTeamId: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TCreateTeamResponse = z.infer<typeof ZCreateTeamResponseSchema>;
|
||||
|
||||
/**
|
||||
* Create a team or pending team depending on the user's subscription or application's billing settings.
|
||||
*/
|
||||
export const createTeam = async ({
|
||||
userId,
|
||||
teamName,
|
||||
teamUrl,
|
||||
}: CreateTeamOptions): Promise<TCreateTeamResponse> => {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
organisationId,
|
||||
inheritMembers,
|
||||
}: CreateTeamOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
subscriptions: true,
|
||||
groups: true,
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isPlatformPlan = await isUserPlatformPlan({
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
});
|
||||
|
||||
let isPaymentRequired = IS_BILLING_ENABLED();
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
|
||||
prices.map((price) => price.id),
|
||||
);
|
||||
|
||||
const hasTeamRelatedSubscription = subscriptionsContainsActivePlan(
|
||||
user.subscriptions,
|
||||
teamRelatedPriceIds,
|
||||
);
|
||||
|
||||
if (isPlatformPlan) {
|
||||
// For platform users, check if they already have any teams
|
||||
const existingTeams = await prisma.team.findMany({
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Payment is required if they already have any team
|
||||
isPaymentRequired = existingTeams.length > 0;
|
||||
} else {
|
||||
// For non-platform users, payment is required if they don't have a team-related subscription
|
||||
isPaymentRequired = !hasTeamRelatedSubscription;
|
||||
}
|
||||
|
||||
customerId = await createTeamCustomer({
|
||||
name: user.name ?? teamName,
|
||||
email: user.email,
|
||||
}).then((customer) => customer.id);
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the team directly if no payment is required.
|
||||
if (!isPaymentRequired) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existingUserProfileWithUrl = await tx.user.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
// Validate they have enough team slots. 0 means they can create unlimited teams.
|
||||
if (organisation.organisationClaim.teamCount !== 0 && IS_BILLING_ENABLED()) {
|
||||
const teamCount = await prisma.team.count({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (teamCount >= organisation.organisationClaim.teamCount) {
|
||||
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
|
||||
message: 'You have reached the maximum number of teams for your plan.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Inherit internal organisation groups to the team.
|
||||
// Organisation Admins/Mangers get assigned as team admins, members get assigned as team members.
|
||||
const internalOrganisationGroups = organisation.groups
|
||||
.filter((group) => {
|
||||
if (group.type !== OrganisationGroupType.INTERNAL_ORGANISATION) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we're inheriting members, allow all internal organisation groups.
|
||||
if (inheritMembers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, only inherit organisation admins/managers.
|
||||
return (
|
||||
group.organisationRole === OrganisationMemberRole.ADMIN ||
|
||||
group.organisationRole === OrganisationMemberRole.MANAGER
|
||||
);
|
||||
})
|
||||
.map((group) =>
|
||||
match(group.organisationRole)
|
||||
.with(OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER, () => ({
|
||||
organisationGroupId: group.id,
|
||||
teamRole: TeamMemberRole.ADMIN,
|
||||
}))
|
||||
.with(OrganisationMemberRole.MEMBER, () => ({
|
||||
organisationGroupId: group.id,
|
||||
teamRole: TeamMemberRole.MEMBER,
|
||||
}))
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
await prisma
|
||||
.$transaction(
|
||||
async (tx) => {
|
||||
const teamSettings = await tx.teamGlobalSettings.create({
|
||||
data: {
|
||||
...generateDefaultTeamSettings(),
|
||||
id: generateDatabaseId('team_setting'),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserProfileWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'URL already taken.',
|
||||
});
|
||||
}
|
||||
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
customerId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId: user.id,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
organisationId,
|
||||
teamGlobalSettingsId: teamSettings.id,
|
||||
teamGroups: {
|
||||
createMany: {
|
||||
// Attach the internal organisation groups to the team.
|
||||
data: internalOrganisationGroups.map((group) => ({
|
||||
...group,
|
||||
id: generateDatabaseId('team_group'),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamGroups: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the internal team groups.
|
||||
await Promise.all(
|
||||
TEAM_INTERNAL_GROUPS.map(async (teamGroup) =>
|
||||
tx.organisationGroup.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_group'),
|
||||
type: teamGroup.type,
|
||||
organisationRole: LOWEST_ORGANISATION_ROLE,
|
||||
organisationId,
|
||||
teamGroups: {
|
||||
create: {
|
||||
id: generateDatabaseId('team_group'),
|
||||
teamId: team.id,
|
||||
teamRole: teamGroup.teamRole,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
paymentRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a pending team if payment is required.
|
||||
const pendingTeam = await prisma.$transaction(async (tx) => {
|
||||
const existingTeamWithUrl = await tx.team.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const existingUserProfileWithUrl = await tx.user.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserProfileWithUrl) {
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
{
|
||||
timeout: 7500,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err.code === 'P2002') {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'URL already taken.',
|
||||
message: 'Team URL already exists',
|
||||
});
|
||||
}
|
||||
|
||||
if (existingTeamWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Team URL already exists.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Missing customer ID for pending teams.',
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.teamPending.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
paymentRequired: true,
|
||||
pendingTeamId: pendingTeam.id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (!(err instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const target = z.array(z.string()).safeParse(err.meta?.target);
|
||||
|
||||
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Team URL already exists.',
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export type CreateTeamFromPendingTeamOptions = {
|
||||
pendingTeamId: number;
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const createTeamFromPendingTeam = async ({
|
||||
pendingTeamId,
|
||||
subscription,
|
||||
}: CreateTeamFromPendingTeamOptions) => {
|
||||
const createdTeam = await prisma.$transaction(async (tx) => {
|
||||
const pendingTeam = await tx.teamPending.findUniqueOrThrow({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamPending.delete({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name: pendingTeam.name,
|
||||
url: pendingTeam.url,
|
||||
ownerUserId: pendingTeam.ownerUserId,
|
||||
customerId: pendingTeam.customerId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId: pendingTeam.ownerUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
|
||||
);
|
||||
|
||||
return team;
|
||||
});
|
||||
|
||||
// Attach the team ID to the subscription metadata for sanity reasons.
|
||||
await stripe.subscriptions
|
||||
.update(subscription.id, {
|
||||
metadata: {
|
||||
teamId: createdTeam.id.toString(),
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
// Non-critical error, but we want to log it so we can rectify it.
|
||||
// Todo: Teams - Alert us.
|
||||
});
|
||||
|
||||
return createdTeam;
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeclineTeamInvitationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const declineTeamInvitation = async ({ userId, teamId }: DeclineTeamInvitationOptions) => {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
|
||||
where: {
|
||||
teamId,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: notify the team owner
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,8 @@
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteTeamEmailVerificationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
@ -10,25 +12,17 @@ export const deleteTeamEmailVerification = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: DeleteTeamEmailVerificationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.team.findFirstOrThrow({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
});
|
||||
|
||||
await tx.teamEmailVerification.delete({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
await prisma.teamEmailVerification.delete({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -5,14 +5,14 @@ import { msg } from '@lingui/core/macro';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { env } from '../../utils/env';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export type DeleteTeamEmailOptions = {
|
||||
userId: number;
|
||||
@ -26,47 +26,49 @@ export type DeleteTeamEmailOptions = {
|
||||
* The user must either be part of the team with the required permissions, or the owner of the email.
|
||||
*/
|
||||
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
|
||||
const team = await prisma.$transaction(async (tx) => {
|
||||
const foundTeam = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
OR: [
|
||||
{
|
||||
teamEmail: {
|
||||
email: userEmail,
|
||||
},
|
||||
},
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
OR: [
|
||||
buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
{
|
||||
id: teamId,
|
||||
teamEmail: {
|
||||
email: userEmail,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
organisation: {
|
||||
select: {
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamEmail.delete({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return foundTeam;
|
||||
await prisma.teamEmail.delete({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
@ -80,28 +82,19 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
const branding = team.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const lang = team.teamGlobalSettings?.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
renderEmailWithI18N(template, { lang, branding, plainText: true }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(lang);
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: team.owner.email,
|
||||
name: team.owner.name ?? '',
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
address: team.organisation.owner.email,
|
||||
name: team.organisation.owner.name ?? '',
|
||||
},
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`Team email has been revoked for ${team.name}`),
|
||||
html,
|
||||
text,
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
|
||||
export type DeleteTeamMemberInvitationsOptions = {
|
||||
/**
|
||||
* The ID of the user who is initiating this action.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The ID of the team to remove members from.
|
||||
*/
|
||||
teamId: number;
|
||||
|
||||
/**
|
||||
* The IDs of the invitations to remove.
|
||||
*/
|
||||
invitationIds: number[];
|
||||
};
|
||||
|
||||
export const deleteTeamMemberInvitations = async ({
|
||||
userId,
|
||||
teamId,
|
||||
invitationIds,
|
||||
}: DeleteTeamMemberInvitationsOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.teamMember.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: invitationIds,
|
||||
},
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user