Merge branch 'main' into feat/webhook-implementation

This commit is contained in:
Lucas Smith
2024-02-26 12:47:21 +11:00
committed by GitHub
196 changed files with 10026 additions and 1102 deletions

View File

@ -1,4 +1,4 @@
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';

View File

@ -1,7 +1,7 @@
import { base32 } from '@scure/base';
import { TOTPController } from 'oslo/otp';
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';

View File

@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
export interface FindDocumentsOptions {
term?: string;

View File

@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { Role } from '@documenso/prisma/client';
import type { Role } from '@documenso/prisma/client';
export type UpdateUserOptions = {
id: number;

View File

@ -1,4 +1,5 @@
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
import crypto from 'crypto';
import { SALT_ROUNDS } from '../../constants/auth';
@ -12,3 +13,7 @@ export const hashSync = (password: string) => {
export const compareSync = (password: string, hash: string) => {
return bcryptCompareSync(password, hash);
};
export const hashString = (input: string) => {
return crypto.createHash('sha512').update(input).digest('hex');
};

View File

@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
}
return upsertedDocumentMeta;
});

View File

@ -10,80 +10,127 @@ import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteDocumentOptions = {
id: number;
userId: number;
status: DocumentStatus;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
export const deleteDocument = async ({
id,
userId,
teamId,
requestMetadata,
}: DeleteDocumentOptions) => {
const document = await prisma.document.findUnique({
where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
documentMeta: true,
User: true,
},
});
if (!document) {
throw new Error('Document not found');
}
const { status, User: user } = document;
// if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) {
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
return await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
},
}),
});
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
});
}
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING) {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const document = await prisma.document.findUnique({
where: {
id,
status,
userId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
// If the document is not a draft, only soft-delete.
return await prisma.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'SOFT',
},
}),
});
return await tx.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
});
};

View File

@ -0,0 +1,115 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { DocumentAuditLog } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface FindDocumentAuditLogsOptions {
userId: number;
documentId: number;
page?: number;
perPage?: number;
orderBy?: {
column: keyof DocumentAuditLog;
direction: 'asc' | 'desc';
};
cursor?: string;
filterForRecentActivity?: boolean;
}
export const findDocumentAuditLogs = async ({
userId,
documentId,
page = 1,
perPage = 30,
orderBy,
cursor,
filterForRecentActivity,
}: FindDocumentAuditLogsOptions) => {
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
const whereClause: Prisma.DocumentAuditLogWhereInput = {
documentId,
};
// Filter events down to what we consider recent activity.
if (filterForRecentActivity) {
whereClause.OR = [
{
type: {
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
],
},
},
{
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
data: {
path: ['isResending'],
equals: true,
},
},
];
}
const [data, count] = await Promise.all([
prisma.documentAuditLog.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage + 1,
orderBy: {
[orderByColumn]: orderByDirection,
},
cursor: cursor ? { id: cursor } : undefined,
}),
prisma.documentAuditLog.count({
where: whereClause,
}),
]);
let nextCursor: string | undefined = undefined;
const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
if (parsedData.length > perPage) {
const nextItem = parsedData.pop();
nextCursor = nextItem!.id;
}
return {
data: parsedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
nextCursor,
} satisfies FindResultSet<typeof parsedData> & { nextCursor?: string };
};

View File

@ -21,6 +21,19 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
include: {
documentData: true,
documentMeta: true,
User: {
select: {
id: true,
name: true,
email: true,
},
},
team: {
select: {
id: true,
url: true,
},
},
},
});
};

View File

@ -110,40 +110,43 @@ export const resendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await prisma.$transaction(async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
}),
});
});
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
};

View File

@ -49,44 +49,47 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
});
await prisma.$transaction(async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
],
});
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
},
],
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
requestMetadata,
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
isResending: false,
},
}),
});
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
requestMetadata,
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
isResending: false,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
};

View File

@ -22,12 +22,14 @@ import { triggerWebhook } from '../../universal/trigger-webhook';
export type SendDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const sendDocument = async ({
documentId,
userId,
teamId,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
@ -44,20 +46,21 @@ export const sendDocument = async ({
const document = await prisma.document.findUnique({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
@ -110,59 +113,76 @@ export const sendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await prisma.$transaction(async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
}),
});
});
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
const updatedDocument = await prisma.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
documentId: document.id,
requestMetadata,
user,
data: {},
}),
});
}
return await tx.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
});
});
await triggerWebhook({

View File

@ -5,16 +5,36 @@ import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type UpdateDocumentOptions = {
documentId: number;
data: Prisma.DocumentUpdateInput;
userId: number;
documentId: number;
teamId?: number;
};
export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => {
export const updateDocument = async ({
documentId,
userId,
teamId,
data,
}: UpdateDocumentOptions) => {
return await prisma.document.update({
where: {
id: documentId,
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
data: {
...data,

View File

@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma';
export type UpdateTitleOptions = {
userId: number;
teamId?: number;
documentId: number;
title: string;
requestMetadata?: RequestMetadata;
@ -14,6 +15,7 @@ export type UpdateTitleOptions = {
export const updateTitle = async ({
userId,
teamId,
documentId,
title,
requestMetadata,
@ -24,34 +26,39 @@ export const updateTitle = async ({
},
});
return await prisma.$transaction(async (tx) => {
const document = await tx.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
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;
}
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,

View File

@ -0,0 +1,126 @@
import { prisma } from '@documenso/prisma';
import type { FieldType, Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type CreateFieldOptions = {
documentId: number;
userId: number;
teamId?: number;
recipientId: number;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
requestMetadata?: RequestMetadata;
};
export const createField = async ({
documentId,
userId,
teamId,
recipientId,
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
requestMetadata,
}: CreateFieldOptions) => {
const document = await prisma.document.findFirst({
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 field = await prisma.field.create({
data: {
documentId,
recipientId,
type,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: pageWidth,
height: pageHeight,
customText: '',
inserted: false,
},
include: {
Recipient: true,
},
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_CREATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: recipientId,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;
};

View File

@ -0,0 +1,90 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const deleteField = async ({
fieldId,
userId,
teamId,
documentId,
requestMetadata,
}: DeleteFieldOptions) => {
const field = await prisma.field.delete({
where: {
id: fieldId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
include: {
Recipient: true,
},
});
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.findFirstOrThrow({
where: {
id: teamId,
},
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: field.recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;
};

View File

@ -0,0 +1,17 @@
import { prisma } from '@documenso/prisma';
export type GetFieldByIdOptions = {
fieldId: number;
documentId: number;
};
export const getFieldById = async ({ fieldId, documentId }: GetFieldByIdOptions) => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
documentId,
},
});
return field;
};

View File

@ -0,0 +1,122 @@
import { prisma } from '@documenso/prisma';
import type { FieldType, Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type UpdateFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId?: number;
recipientId?: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
pageWidth?: number;
pageHeight?: number;
requestMetadata?: RequestMetadata;
};
export const updateField = async ({
fieldId,
documentId,
userId,
teamId,
recipientId,
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
requestMetadata,
}: UpdateFieldOptions) => {
const field = await prisma.field.update({
where: {
id: fieldId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
data: {
recipientId,
type,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: pageWidth,
height: pageHeight,
},
include: {
Recipient: true,
},
});
if (!field) {
throw new Error('Field 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,
},
},
},
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_UPDATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;
};

View File

@ -1,3 +1,4 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, StandardFonts } from 'pdf-lib';
@ -73,13 +74,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
height: imageHeight,
});
} else {
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const 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(field.customText, fontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;

View File

@ -0,0 +1,67 @@
import type { Duration } from 'luxon';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
// temporary choice for testing only
import * as timeConstants from '../../constants/time';
import { alphaid } from '../../universal/id';
import { hashString } from '../auth/hash';
type TimeConstants = typeof timeConstants & {
[key: string]: number | Duration;
};
type CreateApiTokenInput = {
userId: number;
teamId?: number;
tokenName: string;
expiresIn: string | null;
};
export const createApiToken = async ({
userId,
teamId,
tokenName,
expiresIn,
}: CreateApiTokenInput) => {
const apiToken = `api_${alphaid(16)}`;
const hashedToken = hashString(apiToken);
const timeConstantsRecords: TimeConstants = timeConstants;
if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
if (!member) {
throw new Error('You do not have permission to create a token for this team');
}
}
const storedToken = await prisma.apiToken.create({
data: {
name: tokenName,
token: hashedToken,
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
userId: teamId ? null : userId,
teamId,
},
});
if (!storedToken) {
throw new Error('Failed to create the API token');
}
return {
id: storedToken.id,
token: apiToken,
};
};

View File

@ -0,0 +1,32 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type DeleteTokenByIdOptions = {
id: number;
userId: 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,
},
});
if (!member) {
throw new Error('You do not have permission to delete this token');
}
}
return await prisma.apiToken.delete({
where: {
id,
userId: teamId ? null : userId,
teamId,
},
});
};

View File

@ -0,0 +1,36 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type GetUserTokensOptions = {
userId: number;
teamId: number;
};
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
const teamMember = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to view tokens for this team');
}
return await prisma.apiToken.findMany({
where: {
teamId,
},
select: {
id: true,
name: true,
algorithm: true,
createdAt: true,
expires: true,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -0,0 +1,23 @@
import { prisma } from '@documenso/prisma';
export type GetUserTokensOptions = {
userId: number;
};
export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
return await prisma.apiToken.findMany({
where: {
userId,
},
select: {
id: true,
name: true,
algorithm: true,
createdAt: true,
expires: true,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -0,0 +1,15 @@
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,
},
});
};

View File

@ -0,0 +1,41 @@
import { prisma } from '@documenso/prisma';
import { hashString } from '../auth/hash';
export const getApiTokenByToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const apiToken = await prisma.apiToken.findFirst({
where: {
token: hashedToken,
},
include: {
team: true,
user: true,
},
});
if (!apiToken) {
throw new Error('Invalid token');
}
if (apiToken.expires && apiToken.expires < new Date()) {
throw new Error('Expired token');
}
if (apiToken.team) {
apiToken.user = await prisma.user.findFirst({
where: {
id: apiToken.team.ownerUserId,
},
});
}
const { user } = apiToken;
if (!user) {
throw new Error('Invalid token');
}
return { ...apiToken, user };
};

View File

@ -0,0 +1,106 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { SendStatus } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteRecipientOptions = {
documentId: number;
recipientId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const deleteRecipient = async ({
documentId,
recipientId,
userId,
teamId,
requestMetadata,
}: DeleteRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
if (recipient.sendStatus !== SendStatus.NOT_SENT) {
throw new Error('Can not delete a recipient that has already been sent a document');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const deletedRecipient = await prisma.$transaction(async (tx) => {
const deleted = await tx.recipient.delete({
where: {
id: recipient.id,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'RECIPIENT_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
requestMetadata,
}),
});
return deleted;
});
return deletedRecipient;
};

View File

@ -0,0 +1,21 @@
import { prisma } from '@documenso/prisma';
export type GetRecipientByEmailOptions = {
documentId: number;
email: string;
};
export const getRecipientByEmail = async ({ documentId, email }: GetRecipientByEmailOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
documentId,
email: email.toLowerCase(),
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
return recipient;
};

View File

@ -0,0 +1,21 @@
import { prisma } from '@documenso/prisma';
export type GetRecipientByIdOptions = {
id: number;
documentId: number;
};
export const getRecipientById = async ({ documentId, id }: GetRecipientByIdOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
documentId,
id,
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
return recipient;
};

View File

@ -3,11 +3,13 @@ import { prisma } from '@documenso/prisma';
export interface GetRecipientsForDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
}
export const getRecipientsForDocument = async ({
documentId,
userId,
teamId,
}: GetRecipientsForDocumentOptions) => {
const recipients = await prisma.recipient.findMany({
where: {
@ -18,6 +20,7 @@ export const getRecipientsForDocument = async ({
userId,
},
{
teamId,
team: {
members: {
some: {

View File

@ -11,6 +11,7 @@ import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetRecipientsForDocumentOptions {
userId: number;
teamId?: number;
documentId: number;
recipients: {
id?: number | null;
@ -23,6 +24,7 @@ export interface SetRecipientsForDocumentOptions {
export const setRecipientsForDocument = async ({
userId,
teamId,
documentId,
recipients,
requestMetadata,
@ -30,20 +32,21 @@ export const setRecipientsForDocument = async ({
const document = await prisma.document.findFirst({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
});
@ -106,7 +109,7 @@ export const setRecipientsForDocument = async ({
});
const persistedRecipients = await prisma.$transaction(async (tx) => {
await Promise.all(
return await Promise.all(
linkedRecipients.map(async (recipient) => {
const upsertedRecipient = await tx.recipient.upsert({
where: {

View File

@ -1,4 +1,5 @@
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
@ -9,6 +10,7 @@ export type SetRecipientsForTemplateOptions = {
id?: number;
email: string;
name: string;
role: RecipientRole;
}[];
};
@ -84,11 +86,13 @@ export const setRecipientsForTemplate = async ({
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
templateId,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
templateId,
},

View File

@ -0,0 +1,118 @@
import { prisma } from '@documenso/prisma';
import type { RecipientRole, Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
export type UpdateRecipientOptions = {
documentId: number;
recipientId: number;
email?: string;
name?: string;
role?: RecipientRole;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const updateRecipient = async ({
documentId,
recipientId,
email,
name,
role,
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,
}),
},
},
});
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');
}
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,
},
});
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;
};

View File

@ -0,0 +1,9 @@
import { prisma } from '@documenso/prisma';
import { ZSiteSettingsSchema } from './schema';
export const getSiteSettings = async () => {
const settings = await prisma.siteSettings.findMany();
return ZSiteSettingsSchema.parse(settings);
};

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
// TODO: Use `z.union([...])` once we have more than one setting
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
export const ZSiteSettingsSchema = z.array(ZSiteSettingSchema);
export type TSiteSettingsSchema = z.infer<typeof ZSiteSettingsSchema>;

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZSiteSettingsBaseSchema = z.object({
id: z.string().min(1),
enabled: z.boolean(),
data: z.never(),
});
export type TSiteSettingsBaseSchema = z.infer<typeof ZSiteSettingsBaseSchema>;

View File

@ -0,0 +1,23 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_BANNER_ID = 'site.banner';
export const ZSiteSettingsBannerSchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_BANNER_ID),
data: z
.object({
content: z.string(),
bgColor: z.string(),
textColor: z.string(),
})
.optional()
.default({
content: '',
bgColor: '#000000',
textColor: '#FFFFFF',
}),
});
export type TSiteSettingsBannerSchema = z.infer<typeof ZSiteSettingsBannerSchema>;

View File

@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
import type { TSiteSettingSchema } from './schema';
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
userId: number;
};
export const upsertSiteSetting = async ({
id,
enabled,
data,
userId,
}: UpsertSiteSettingOptions) => {
return await prisma.siteSettings.upsert({
where: {
id,
},
create: {
id,
enabled,
data,
lastModifiedByUserId: userId,
lastModifiedAt: new Date(),
},
update: {
enabled,
data,
lastModifiedByUserId: userId,
lastModifiedAt: new Date(),
},
});
};

View File

@ -9,55 +9,58 @@ export type AcceptTeamInvitationOptions = {
};
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
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,
},
include: {
team: {
include: {
subscription: true,
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
where: {
teamId,
email: user.email,
},
include: {
team: {
include: {
subscription: true,
},
},
},
},
});
});
const { team } = teamMemberInvite;
const { team } = teamMemberInvite;
await tx.teamMember.create({
data: {
teamId: teamMemberInvite.teamId,
userId: user.id,
role: teamMemberInvite.role,
},
});
await tx.teamMemberInvite.delete({
where: {
id: teamMemberInvite.id,
},
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
await tx.teamMember.create({
data: {
teamId: teamMemberInvite.teamId,
userId: user.id,
role: teamMemberInvite.role,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
await tx.teamMemberInvite.delete({
where: {
id: teamMemberInvite.id,
},
});
}
});
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,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -28,56 +28,59 @@ export const createTeamEmailVerification = async ({
data,
}: CreateTeamEmailVerificationOptions) => {
try {
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'],
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,
},
});
include: {
teamEmail: true,
emailVerification: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Team already has an email or existing email verification.',
);
}
if (team.teamEmail || team.emailVerification) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Team already has an email or existing email verification.',
);
}
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
});
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
});
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
}
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
},
{ timeout: 30_000 },
);
} catch (err) {
console.error(err);

View File

@ -27,76 +27,81 @@ export const deleteTeamMembers = async ({
teamId,
teamMemberIds,
}: DeleteTeamMembersOptions) => {
await prisma.$transaction(async (tx) => {
// Find the team and validate that the user is allowed to remove members.
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
await prisma.$transaction(
async (tx) => {
// Find the team and validate that the user is allowed to remove members.
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
},
include: {
members: {
select: {
id: true,
userId: true,
role: true,
include: {
members: {
select: {
id: true,
userId: true,
role: true,
},
},
subscription: true,
},
});
const currentTeamMember = team.members.find((member) => member.userId === userId);
const teamMembersToRemove = team.members.filter((member) =>
teamMemberIds.includes(member.id),
);
if (!currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
}
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
}
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
);
if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
}
// Remove the team members.
await tx.teamMember.deleteMany({
where: {
id: {
in: teamMemberIds,
},
teamId,
userId: {
not: team.ownerUserId,
},
},
subscription: true,
},
});
const currentTeamMember = team.members.find((member) => member.userId === userId);
const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id));
if (!currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
}
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
}
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
);
if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
}
// Remove the team members.
await tx.teamMember.deleteMany({
where: {
id: {
in: teamMemberIds,
},
teamId,
userId: {
not: team.ownerUserId,
},
},
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -9,34 +9,37 @@ export type DeleteTeamOptions = {
};
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
},
include: {
subscription: true,
},
});
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
},
include: {
subscription: true,
},
});
if (team.subscription) {
await stripe.subscriptions
.cancel(team.subscription.planId, {
prorate: false,
invoice_now: true,
})
.catch((err) => {
console.error(err);
throw AppError.parseError(err);
});
}
if (team.subscription) {
await stripe.subscriptions
.cancel(team.subscription.planId, {
prorate: false,
invoice_now: true,
})
.catch((err) => {
console.error(err);
throw AppError.parseError(err);
});
}
await tx.team.delete({
where: {
id: teamId,
ownerUserId: userId,
},
});
});
await tx.team.delete({
where: {
id: teamId,
ownerUserId: userId,
},
});
},
{ timeout: 30_000 },
);
};

View File

@ -15,45 +15,48 @@ export type LeaveTeamOptions = {
};
export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: {
not: userId,
},
},
include: {
subscription: true,
},
});
await tx.teamMember.delete({
where: {
userId_teamId: {
userId,
teamId,
},
team: {
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: {
not: userId,
},
},
},
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
include: {
subscription: true,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
await tx.teamMember.delete({
where: {
userId_teamId: {
userId,
teamId,
},
team: {
ownerUserId: {
not: userId,
},
},
},
});
}
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -44,63 +44,66 @@ export const requestTeamOwnershipTransfer = async ({
// Todo: Clear payment methods disabled for now.
const clearPaymentMethods = false;
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
members: {
some: {
userId: newOwnerUserId,
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
members: {
some: {
userId: newOwnerUserId,
},
},
},
},
});
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
});
const { token, expiresAt } = createTokenVerification({ minute: 10 });
const { token, expiresAt } = createTokenVerification({ minute: 10 });
const teamVerificationPayload = {
teamId,
token,
expiresAt,
userId: newOwnerUserId,
name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
clearPaymentMethods,
};
await tx.teamTransferVerification.upsert({
where: {
const teamVerificationPayload = {
teamId,
},
create: teamVerificationPayload,
update: teamVerificationPayload,
});
token,
expiresAt,
userId: newOwnerUserId,
name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
clearPaymentMethods,
};
const template = createElement(TeamTransferRequestTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
senderName: userName,
teamName: team.name,
teamUrl: team.url,
token,
});
await tx.teamTransferVerification.upsert({
where: {
teamId,
},
create: teamVerificationPayload,
update: teamVerificationPayload,
});
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
});
const template = createElement(TeamTransferRequestTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
senderName: userName,
teamName: team.name,
teamUrl: team.url,
token,
});
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
},
{ timeout: 30_000 },
);
};

View File

@ -17,49 +17,52 @@ export const resendTeamEmailVerification = async ({
userId,
teamId,
}: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
},
include: {
emailVerification: true,
},
});
include: {
emailVerification: true,
},
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
}
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
}
const { emailVerification } = team;
const { emailVerification } = team;
if (!emailVerification) {
throw new AppError(
'VerificationNotFound',
'No team email verification exists for this team.',
);
}
if (!emailVerification) {
throw new AppError(
'VerificationNotFound',
'No team email verification exists for this team.',
);
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
},
{ timeout: 30_000 },
);
};

View File

@ -35,42 +35,45 @@ export const resendTeamMemberInvitation = async ({
teamId,
invitationId,
}: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
},
});
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
}
if (!team) {
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
}
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
where: {
id: invitationId,
teamId,
},
});
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
where: {
id: invitationId,
teamId,
},
});
if (!teamMemberInvite) {
throw new AppError('InviteNotFound', 'No invite exists for this user.');
}
if (!teamMemberInvite) {
throw new AppError('InviteNotFound', 'No invite exists for this user.');
}
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
teamName: team.name,
teamUrl: team.url,
senderName: userName,
});
});
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
teamName: team.name,
teamUrl: team.url,
senderName: userName,
});
},
{ timeout: 30_000 },
);
};

View File

@ -11,78 +11,81 @@ export type TransferTeamOwnershipOptions = {
};
export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => {
await prisma.$transaction(async (tx) => {
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
where: {
token,
},
include: {
team: {
include: {
subscription: true,
await prisma.$transaction(
async (tx) => {
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
where: {
token,
},
include: {
team: {
include: {
subscription: true,
},
},
},
},
});
const { team, userId: newOwnerUserId } = teamTransferVerification;
await tx.teamTransferVerification.delete({
where: {
teamId: team.id,
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
teamMembers: {
some: {
teamId: team.id,
},
},
},
include: {
Subscription: true,
},
});
let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED()) {
teamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
});
}
if (teamSubscription) {
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
);
}
const { team, userId: newOwnerUserId } = teamTransferVerification;
await tx.team.update({
where: {
id: team.id,
},
data: {
ownerUserId: newOwnerUserId,
members: {
update: {
where: {
userId_teamId: {
teamId: team.id,
userId: newOwnerUserId,
await tx.teamTransferVerification.delete({
where: {
teamId: team.id,
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
teamMembers: {
some: {
teamId: team.id,
},
},
},
include: {
Subscription: true,
},
});
let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED()) {
teamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
});
}
if (teamSubscription) {
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
);
}
await tx.team.update({
where: {
id: team.id,
},
data: {
ownerUserId: newOwnerUserId,
members: {
update: {
where: {
userId_teamId: {
teamId: team.id,
userId: newOwnerUserId,
},
},
data: {
role: TeamMemberRole.ADMIN,
},
},
data: {
role: TeamMemberRole.ADMIN,
},
},
},
},
});
});
});
},
{ timeout: 30_000 },
);
};

View File

@ -1,32 +1,42 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentFromTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
import type { RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateOptions = TCreateDocumentFromTemplateMutationSchema & {
export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
name?: string;
email: string;
role?: RecipientRole;
}[];
};
export const createDocumentFromTemplate = async ({
templateId,
userId,
teamId,
recipients,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
@ -57,13 +67,18 @@ export const createDocumentFromTemplate = async ({
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
include: {
Recipient: true,
Recipient: {
orderBy: {
id: 'asc',
},
},
},
});
@ -88,5 +103,34 @@ export const createDocumentFromTemplate = async ({
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
}),
);
}
return document;
};

View File

@ -38,6 +38,7 @@ export const findTemplates = async ({
include: {
templateDocumentData: true,
Field: true,
Recipient: true,
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {

View File

@ -53,47 +53,50 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
await Promise.allSettled(
acceptedTeamInvites.map(async (invite) =>
prisma
.$transaction(async (tx) => {
await tx.teamMember.create({
data: {
teamId: invite.teamId,
userId: user.id,
role: invite.role,
},
});
await tx.teamMemberInvite.delete({
where: {
id: invite.id,
},
});
if (!IS_BILLING_ENABLED()) {
return;
}
const team = await tx.team.findFirstOrThrow({
where: {
id: invite.teamId,
},
include: {
members: {
select: {
id: true,
},
.$transaction(
async (tx) => {
await tx.teamMember.create({
data: {
teamId: invite.teamId,
userId: user.id,
role: invite.role,
},
subscription: true,
},
});
if (team.subscription) {
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: team.members.length,
});
}
})
await tx.teamMemberInvite.delete({
where: {
id: invite.id,
},
});
if (!IS_BILLING_ENABLED()) {
return;
}
const team = await tx.team.findFirstOrThrow({
where: {
id: invite.teamId,
},
include: {
members: {
select: {
id: true,
},
},
subscription: true,
},
});
if (team.subscription) {
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: team.members.length,
});
}
},
{ timeout: 30_000 },
)
.catch(async () => {
await prisma.teamMemberInvite.update({
where: {

View File

@ -1,4 +1,7 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
export type DeleteUserOptions = {
email: string;
@ -17,6 +20,22 @@ export const deleteUser = async ({ email }: DeleteUserOptions) => {
throw new Error(`User with email ${email} not found`);
}
const serviceAccount = await deletedAccountServiceAccount();
// TODO: Send out cancellations for all pending docs
await prisma.document.updateMany({
where: {
userId: user.id,
status: {
in: [DocumentStatus.PENDING, DocumentStatus.COMPLETED],
},
},
data: {
userId: serviceAccount.id,
deletedAt: new Date(),
},
});
return await prisma.user.delete({
where: {
id: user.id,

View File

@ -0,0 +1,17 @@
import { prisma } from '@documenso/prisma';
export const deletedAccountServiceAccount = async () => {
const serviceAccount = await prisma.user.findFirst({
where: {
email: 'deleted-account@documenso.com',
},
});
if (!serviceAccount) {
throw new Error(
'Deleted account service account not found, have you ran the appropriate migrations?',
);
}
return serviceAccount;
};