Merge branch 'main' into feat/account-deletion

This commit is contained in:
Ephraim Duncan
2024-02-14 14:54:26 +00:00
committed by GitHub
268 changed files with 15575 additions and 1319 deletions

View File

@@ -13,21 +13,25 @@ export const decryptSecondaryData = (encryptedData: string): string | null => {
throw new Error('Missing encryption key');
}
const decryptedBufferValue = symmetricDecrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: encryptedData,
});
try {
const decryptedBufferValue = symmetricDecrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: encryptedData,
});
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
if (!result.success) {
if (!result.success) {
return null;
}
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
return null;
}
return result.data.data;
} catch {
return null;
}
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
return null;
}
return result.data.data;
};

View File

@@ -1,7 +1,7 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentDataType } from '@documenso/prisma/client';
import type { DocumentDataType } from '@documenso/prisma/client';
export type CreateDocumentDataOptions = {
type: DocumentDataType;

View File

@@ -1,5 +1,11 @@
'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
export type CreateDocumentMetaOptions = {
@@ -9,7 +15,9 @@ export type CreateDocumentMetaOptions = {
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
userId: number;
requestMetadata: RequestMetadata;
};
export const upsertDocumentMeta = async ({
@@ -18,34 +26,81 @@ export const upsertDocumentMeta = async ({
timezone,
dateFormat,
documentId,
userId,
password,
userId,
redirectUrl,
requestMetadata,
}: CreateDocumentMetaOptions) => {
await prisma.document.findFirstOrThrow({
const user = await prisma.user.findFirstOrThrow({
where: {
id: documentId,
userId,
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
return await prisma.documentMeta.upsert({
const { documentMeta: originalDocumentMeta } = await prisma.document.findFirstOrThrow({
where: {
documentId,
id: documentId,
OR: [
{
userId: user.id,
},
{
team: {
members: {
some: {
userId: user.id,
},
},
},
},
],
},
create: {
subject,
message,
dateFormat,
timezone,
password,
documentId,
},
update: {
subject,
message,
dateFormat,
password,
timezone,
include: {
documentMeta: true,
},
});
return await prisma.$transaction(async (tx) => {
const upsertedDocumentMeta = await tx.documentMeta.upsert({
where: {
documentId,
},
create: {
subject,
message,
password,
dateFormat,
timezone,
documentId,
redirectUrl,
},
update: {
subject,
message,
password,
dateFormat,
timezone,
redirectUrl,
},
});
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

@@ -1,5 +1,8 @@
'use server';
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';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
@@ -9,11 +12,13 @@ import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
requestMetadata?: RequestMetadata;
};
export const completeDocumentWithToken = async ({
token,
documentId,
requestMetadata,
}: CompleteDocumentWithTokenOptions) => {
'use server';
@@ -70,6 +75,24 @@ export const completeDocumentWithToken = async ({
},
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
}),
});
const pendingRecipients = await prisma.recipient.count({
where: {
documentId: document.id,
@@ -99,6 +122,6 @@ export const completeDocumentWithToken = async ({
});
if (documents.count > 0) {
await sealDocument({ documentId: document.id });
await sealDocument({ documentId: document.id, requestMetadata });
}
};

View File

@@ -1,19 +1,68 @@
'use server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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 CreateDocumentOptions = {
title: string;
userId: number;
teamId?: number;
documentDataId: string;
requestMetadata?: RequestMetadata;
};
export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => {
return await prisma.document.create({
data: {
title,
documentDataId,
userId,
export const createDocument = async ({
userId,
title,
documentDataId,
teamId,
requestMetadata,
}: CreateDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
teamMembers: {
select: {
teamId: true,
},
},
},
});
if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
documentDataId,
userId,
teamId,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
data: {
title,
},
}),
});
return document;
});
};

View File

@@ -1,16 +1,27 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentByIdOptions {
id: number;
userId: number;
teamId?: number;
}
export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByIdOptions) => {
export const duplicateDocumentById = async ({
id,
userId,
teamId,
}: DuplicateDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId: id,
userId,
teamId,
});
const document = await prisma.document.findUniqueOrThrow({
where: {
id,
userId: userId,
},
where: documentWhereInput,
select: {
title: true,
userId: true,
@@ -28,12 +39,13 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
dateFormat: true,
password: true,
timezone: true,
redirectUrl: true,
},
},
},
});
const createdDocument = await prisma.document.create({
const createDocumentArguments: Prisma.DocumentCreateArgs = {
data: {
title: document.title,
User: {
@@ -53,7 +65,17 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
},
},
},
});
};
if (teamId !== undefined) {
createDocumentArguments.data.team = {
connect: {
id: teamId,
},
};
}
const createdDocument = await prisma.document.create(createDocumentArguments);
return createdDocument.id;
};

View File

@@ -2,8 +2,8 @@ import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Prisma } from '@documenso/prisma/client';
import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { FindResultSet } from '../../types/find-result-set';
@@ -13,6 +13,7 @@ export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
export type FindDocumentsOptions = {
userId: number;
teamId?: number;
term?: string;
status?: ExtendedDocumentStatus;
page?: number;
@@ -22,21 +23,49 @@ export type FindDocumentsOptions = {
direction: 'asc' | 'desc';
};
period?: PeriodSelectorValue;
senderIds?: number[];
};
export const findDocuments = async ({
userId,
teamId,
term,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
orderBy,
period,
senderIds,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team = null;
if (teamId !== undefined) {
team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamEmail: true,
},
});
}
return {
user,
team,
};
});
const orderByColumn = orderBy?.column ?? 'createdAt';
@@ -53,96 +82,34 @@ export const findDocuments = async ({
})
.otherwise(() => undefined);
const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
OR: [
{
userId,
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
},
},
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
},
},
deletedAt: null,
},
],
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
deletedAt: null,
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId,
status: ExtendedDocumentStatus.DRAFT,
deletedAt: null,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId,
status: ExtendedDocumentStatus.PENDING,
deletedAt: null,
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
deletedAt: null,
},
],
}))
.with(ExtendedDocumentStatus.COMPLETED, () => ({
OR: [
{
userId,
status: ExtendedDocumentStatus.COMPLETED,
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
},
},
},
],
}))
.exhaustive();
const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user);
const whereClause = {
if (filters === null) {
return {
data: [],
count: 0,
currentPage: 1,
perPage,
totalPages: 0,
};
}
const whereClause: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
AND: {
OR: [
{
status: ExtendedDocumentStatus.COMPLETED,
},
{
status: {
not: ExtendedDocumentStatus.COMPLETED,
},
deletedAt: null,
},
],
},
};
if (period) {
@@ -155,6 +122,12 @@ export const findDocuments = async ({
};
}
if (senderIds && senderIds.length > 0) {
whereClause.userId = {
in: senderIds,
};
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: whereClause,
@@ -172,13 +145,16 @@ export const findDocuments = async ({
},
},
Recipient: true,
team: {
select: {
id: true,
url: true,
},
},
},
}),
prisma.document.count({
where: {
...termFilters,
...filters,
},
where: whereClause,
}),
]);
@@ -197,3 +173,268 @@ export const findDocuments = async ({
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
};
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
OR: [
{
userId: user.id,
teamId: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
},
},
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
},
},
},
],
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.DRAFT,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
},
],
}))
.with(ExtendedDocumentStatus.COMPLETED, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
},
},
},
],
}))
.exhaustive();
};
/**
* Create a Prisma filter for the Document schema to find documents for a team.
*
* Status All:
* - Documents that belong to the team
* - Documents that have been sent by the team email
* - Non draft documents that have been sent to the team email
*
* Status Inbox:
* - Non draft documents that have been sent to the team email that have not been signed
*
* Status Draft:
* - Documents that belong to the team that are draft
* - Documents that belong to the team email that are draft
*
* Status Pending:
* - Documents that belong to the team that are pending
* - Documents that have been sent by the team email that is pending to be signed by someone else
* - Documents that have been sent to the team email that is pending to be signed by someone else
*
* Status Completed:
* - Documents that belong to the team that are completed
* - Documents that have been sent to the team email that are completed
* - Documents that have been sent by the team email that are completed
*
* @param status The status of the documents to find.
* @param team The team to find the documents for.
* @returns A filter which can be applied to the Prisma Document schema.
*/
const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
) => {
const teamEmail = team.teamEmail?.email ?? null;
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
.with(ExtendedDocumentStatus.ALL, () => {
const filter: Prisma.DocumentWhereInput = {
// Filter to display all documents that belong to the team.
OR: [
{
teamId: team.id,
},
],
};
if (teamEmail && filter.OR) {
// Filter to display all documents received by the team email that are not draft.
filter.OR.push({
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: teamEmail,
},
},
});
// Filter to display all documents that have been sent by the team email.
filter.OR.push({
User: {
email: teamEmail,
},
});
}
return filter;
})
.with(ExtendedDocumentStatus.INBOX, () => {
// Return a filter that will return nothing.
if (!teamEmail) {
return null;
}
return {
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
};
})
.with(ExtendedDocumentStatus.DRAFT, () => {
const filter: Prisma.DocumentWhereInput = {
OR: [
{
teamId: team.id,
status: ExtendedDocumentStatus.DRAFT,
},
],
};
if (teamEmail && filter.OR) {
filter.OR.push({
status: ExtendedDocumentStatus.DRAFT,
User: {
email: teamEmail,
},
});
}
return filter;
})
.with(ExtendedDocumentStatus.PENDING, () => {
const filter: Prisma.DocumentWhereInput = {
OR: [
{
teamId: team.id,
status: ExtendedDocumentStatus.PENDING,
},
],
};
if (teamEmail && filter.OR) {
filter.OR.push({
status: ExtendedDocumentStatus.PENDING,
OR: [
{
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
},
{
User: {
email: teamEmail,
},
},
],
});
}
return filter;
})
.with(ExtendedDocumentStatus.COMPLETED, () => {
const filter: Prisma.DocumentWhereInput = {
status: ExtendedDocumentStatus.COMPLETED,
OR: [
{
teamId: team.id,
},
],
};
if (teamEmail && filter.OR) {
filter.OR.push(
{
Recipient: {
some: {
email: teamEmail,
},
},
},
{
User: {
email: teamEmail,
},
},
);
}
return filter;
})
.exhaustive();
};

View File

@@ -1,19 +1,106 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export interface GetDocumentByIdOptions {
import { getTeamById } from '../team/get-team';
export type GetDocumentByIdOptions = {
id: number;
userId: number;
}
teamId?: number;
};
export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId: id,
userId,
teamId,
});
export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) => {
return await prisma.document.findFirstOrThrow({
where: {
id,
userId,
},
where: documentWhereInput,
include: {
documentData: true,
documentMeta: true,
},
});
};
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;
};
/**
* Generate the where input for a given Prisma document query.
*
* This will return a query that allows a user to get a document if they have valid access to it.
*/
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,
});
}
// Allow access to only team documents.
if (!overlapUserTeamScope) {
documentWhereInput.OR = [
{
teamId: team.id,
},
];
}
// Allow access to documents sent to or from the team email.
if (team.teamEmail) {
documentWhereInput.OR.push(
{
Recipient: {
some: {
email: team.teamEmail.email,
},
},
},
{
User: {
email: team.teamEmail.email,
},
},
);
}
return documentWhereInput;
};

View File

@@ -27,6 +27,7 @@ export const getDocumentAndSenderByToken = async ({
include: {
User: true,
documentData: true,
documentMeta: true,
},
});

View File

@@ -1,19 +1,19 @@
import { DateTime } from 'luxon';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { prisma } from '@documenso/prisma';
import type { Prisma, User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { PeriodSelectorValue } from './find-documents';
export type GetStatsInput = {
user: User;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
};
export const getStats = async ({ user, period }: GetStatsInput) => {
export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@@ -26,7 +26,52 @@ export const getStats = async ({ user, period }: GetStatsInput) => {
};
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
? getTeamCounts({ ...options.team, createdAt })
: getCounts({ user, createdAt }));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
ownerCounts.forEach((stat) => {
stats[stat.status] = stat._count._all;
});
notSignedCounts.forEach((stat) => {
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
});
hasSignedCounts.forEach((stat) => {
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
}
if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
}
});
Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key];
}
});
return stats;
};
type GetCountsOption = {
user: User;
createdAt: Prisma.DocumentWhereInput['createdAt'];
};
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
return Promise.all([
prisma.document.groupBy({
by: ['status'],
_count: {
@@ -35,6 +80,7 @@ export const getStats = async ({ user, period }: GetStatsInput) => {
where: {
userId: user.id,
createdAt,
teamId: null,
deletedAt: null,
},
}),
@@ -91,38 +137,116 @@ export const getStats = async ({ user, period }: GetStatsInput) => {
},
}),
]);
};
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
type GetTeamCountsOption = {
teamId: number;
teamEmail?: string;
senderIds?: number[];
createdAt: Prisma.DocumentWhereInput['createdAt'];
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
const { createdAt, teamId, teamEmail } = options;
const senderIds = options.senderIds ?? [];
const userIdWhereClause: Prisma.DocumentWhereInput['userId'] =
senderIds.length > 0
? {
in: senderIds,
}
: undefined;
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
};
ownerCounts.forEach((stat) => {
stats[stat.status] = stat._count._all;
});
let notSignedCountsGroupByArgs = null;
let hasSignedCountsGroupByArgs = null;
notSignedCounts.forEach((stat) => {
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
});
if (teamEmail) {
ownerCountsWhereInput = {
userId: userIdWhereClause,
createdAt,
OR: [
{
teamId,
},
{
User: {
email: teamEmail,
},
},
],
deletedAt: null,
};
hasSignedCounts.forEach((stat) => {
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
}
notSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
userId: userIdWhereClause,
createdAt,
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
},
},
deletedAt: null,
},
} satisfies Prisma.DocumentGroupByArgs;
if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
}
});
hasSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
userId: userIdWhereClause,
createdAt,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
},
},
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
},
},
deletedAt: null,
},
],
},
} satisfies Prisma.DocumentGroupByArgs;
}
Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key];
}
});
return stats;
return Promise.all([
prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: ownerCountsWhereInput,
}),
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
]);
};

View File

@@ -4,30 +4,49 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
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,
} from '@documenso/lib/constants/recipient-roles';
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 { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
documentId: number;
userId: number;
recipients: number[];
teamId?: number;
requestMetadata: RequestMetadata;
};
export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => {
export const resendDocument = async ({
documentId,
userId,
recipients,
teamId,
requestMetadata,
}: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const documentWhereInput: Prisma.DocumentWhereUniqueInput = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
userId,
},
where: documentWhereInput,
include: {
Recipient: {
where: {
@@ -65,6 +84,8 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const customEmailTemplate = {
@@ -88,20 +109,39 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
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 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,
},
}),
});
});
}),
);

View File

@@ -5,10 +5,13 @@ import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@@ -17,9 +20,14 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
documentId: number;
sendEmail?: boolean;
requestMetadata?: RequestMetadata;
};
export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => {
export const sealDocument = async ({
documentId,
sendEmail = true,
requestMetadata,
}: SealDocumentOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
@@ -100,16 +108,30 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
});
}
await prisma.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData,
},
await prisma.$transaction(async (tx) => {
await tx.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
},
}),
});
});
if (sendEmail) {
await sendCompletedEmail({ documentId });
await sendCompletedEmail({ documentId, requestMetadata });
}
};

View File

@@ -5,13 +5,17 @@ import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface SendDocumentOptions {
documentId: number;
requestMetadata?: RequestMetadata;
}
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDocumentOptions) => {
const document = await prisma.document.findUnique({
where: {
id: documentId,
@@ -44,24 +48,43 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
});
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,
},
}),
});
});
}),
);

View File

@@ -4,28 +4,56 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
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,
} from '@documenso/lib/constants/recipient-roles';
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 { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type SendDocumentOptions = {
documentId: number;
userId: number;
requestMetadata?: RequestMetadata;
};
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
export const sendDocument = async ({
documentId,
userId,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
Recipient: true,
@@ -53,6 +81,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const customEmailTemplate = {
@@ -76,29 +106,48 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
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 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 prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
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,
},
}),
});
});
}),
);

View File

@@ -1,21 +1,76 @@
'use server';
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;
documentId: number;
title: string;
requestMetadata?: RequestMetadata;
};
export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOptions) => {
return await prisma.document.update({
export const updateTitle = async ({
userId,
documentId,
title,
requestMetadata,
}: UpdateTitleOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: documentId,
userId,
},
data: {
title,
id: userId,
},
});
return await prisma.$transaction(async (tx) => {
const document = await tx.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
if (document.title === title) {
return document;
}
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
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;
});
};

View File

@@ -1,11 +1,15 @@
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';
import { ReadStatus } from '@documenso/prisma/client';
export type ViewedDocumentOptions = {
token: string;
requestMetadata?: RequestMetadata;
};
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
@@ -13,16 +17,38 @@ export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
},
});
if (!recipient) {
if (!recipient || !recipient.documentId) {
return;
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
readStatus: ReadStatus.OPENED,
},
const { documentId } = recipient;
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
readStatus: ReadStatus.OPENED,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
},
}),
});
});
};

View File

@@ -10,7 +10,20 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
where: {
documentId,
Document: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@@ -10,7 +10,20 @@ export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForT
where: {
templateId,
Template: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@@ -1,16 +1,21 @@
'use server';
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';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
export type RemovedSignedFieldWithTokenOptions = {
token: string;
fieldId: number;
requestMetadata?: RequestMetadata;
};
export const removeSignedFieldWithToken = async ({
token,
fieldId,
requestMetadata,
}: RemovedSignedFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
@@ -44,8 +49,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
await Promise.all([
prisma.field.update({
await prisma.$transaction(async (tx) => {
await tx.field.update({
where: {
id: field.id,
},
@@ -53,11 +58,28 @@ export const removeSignedFieldWithToken = async ({
customText: '',
inserted: false,
},
}),
prisma.signature.deleteMany({
});
await tx.signature.deleteMany({
where: {
fieldId: field.id,
},
}),
]);
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
documentId: document.id,
user: {
name: recipient?.name,
email: recipient?.email,
},
requestMetadata,
data: {
field: field.type,
fieldId: field.secondaryId,
},
}),
});
});
};

View File

@@ -1,3 +1,9 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
@@ -15,17 +21,43 @@ export interface SetFieldsForDocumentOptions {
pageWidth: number;
pageHeight: number;
}[];
requestMetadata?: RequestMetadata;
}
export const setFieldsForDocument = async ({
userId,
documentId,
fields,
requestMetadata,
}: SetFieldsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
@@ -33,6 +65,10 @@ export const setFieldsForDocument = async ({
throw new Error('Document not found');
}
if (document.completedAt) {
throw new Error('Document already complete');
}
const existingFields = await prisma.field.findMany({
where: {
documentId,
@@ -43,11 +79,7 @@ export const setFieldsForDocument = async ({
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
(existingField) => !fields.find((field) => field.id === existingField.id),
);
const linkedFields = fields
@@ -66,56 +98,123 @@ export const setFieldsForDocument = async ({
);
});
const persistedFields = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map((field) =>
prisma.field.upsert({
where: {
id: field._persisted?.id ?? -1,
documentId,
},
update: {
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
const persistedFields = await prisma.$transaction(async (tx) => {
await Promise.all(
linkedFields.map(async (field) => {
const fieldSignerEmail = field.signerEmail.toLowerCase();
const upsertedField = await tx.field.upsert({
where: {
id: field._persisted?.id ?? -1,
documentId,
},
Recipient: {
connect: {
documentId_email: {
documentId,
email: field.signerEmail.toLowerCase(),
update: {
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
},
Recipient: {
connect: {
documentId_email: {
documentId,
email: fieldSignerEmail,
},
},
},
},
},
});
if (upsertedField.recipientId === null) {
throw new Error('Not possible');
}
const baseAuditLog = {
fieldId: upsertedField.secondaryId,
fieldRecipientEmail: fieldSignerEmail,
fieldRecipientId: upsertedField.recipientId,
fieldType: upsertedField.type,
};
const changes = field._persisted ? diffFieldChanges(field._persisted, upsertedField) : [];
// Handle field updated audit log.
if (field._persisted && changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId: documentId,
user,
requestMetadata,
data: {
changes,
...baseAuditLog,
},
}),
});
}
// Handle field created audit log.
if (!field._persisted) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
documentId: documentId,
user,
requestMetadata,
data: {
...baseAuditLog,
},
}),
});
}
return upsertedField;
}),
),
);
);
});
if (removedFields.length > 0) {
await prisma.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
await prisma.$transaction(async (tx) => {
await tx.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
},
},
},
});
await tx.documentAuditLog.createMany({
data: removedFields.map((field) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
documentId: documentId,
user,
requestMetadata,
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: field.recipientId ?? -1,
fieldType: field.type,
},
}),
),
});
});
}

View File

@@ -27,7 +27,20 @@ export const setFieldsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

View File

@@ -1,18 +1,23 @@
'use server';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
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';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type SignFieldWithTokenOptions = {
token: string;
fieldId: number;
value: string;
isBase64?: boolean;
requestMetadata?: RequestMetadata;
};
export const signFieldWithToken = async ({
@@ -20,6 +25,7 @@ export const signFieldWithToken = async ({
fieldId,
value,
isBase64,
requestMetadata,
}: SignFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
@@ -40,6 +46,10 @@ export const signFieldWithToken = async ({
throw new Error(`Document not found for field ${field.id}`);
}
if (!recipient) {
throw new Error(`Recipient not found for field ${field.id}`);
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
@@ -123,6 +133,38 @@ export const signFieldWithToken = async ({
});
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
documentId: document.id,
user: {
email: recipient.email,
name: recipient.name,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
fieldId: updatedField.secondaryId,
field: match(updatedField.type)
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({
type,
data: signatureImageAsBase64 || typedSignature || '',
}))
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
type,
data: updatedField.customText,
}))
.exhaustive(),
fieldSecurity: {
type: 'NONE',
},
},
}),
});
return updatedField;
});
};

View File

@@ -13,7 +13,20 @@ export const getRecipientsForDocument = async ({
where: {
documentId,
Document: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@@ -13,7 +13,20 @@ export const getRecipientsForTemplate = async ({
where: {
templateId,
Template: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@@ -1,9 +1,14 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import {
createDocumentAuditLogData,
diffRecipientChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
export interface SetRecipientsForDocumentOptions {
userId: number;
documentId: number;
@@ -13,17 +18,43 @@ export interface SetRecipientsForDocumentOptions {
name: string;
role: RecipientRole;
}[];
requestMetadata?: RequestMetadata;
}
export const setRecipientsForDocument = async ({
userId,
documentId,
recipients,
requestMetadata,
}: SetRecipientsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
@@ -31,6 +62,10 @@ export const setRecipientsForDocument = async ({
throw new Error('Document not found');
}
if (document.completedAt) {
throw new Error('Document already complete');
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -64,49 +99,127 @@ export const setRecipientsForDocument = async ({
})
.filter((recipient) => {
return (
recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED
recipient._persisted?.role === RecipientRole.CC ||
(recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
);
});
const persistedRecipients = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedRecipients.map((recipient) =>
prisma.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
documentId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
documentId,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
const persistedRecipients = await prisma.$transaction(async (tx) => {
await Promise.all(
linkedRecipients.map(async (recipient) => {
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
documentId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
});
const recipientId = upsertedRecipient.id;
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
recipient._persisted &&
recipient._persisted.role !== recipient.role &&
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId,
},
});
}
const baseAuditLog = {
recipientEmail: upsertedRecipient.email,
recipientName: upsertedRecipient.name,
recipientId,
recipientRole: upsertedRecipient.role,
};
const changes = recipient._persisted
? diffRecipientChanges(recipient._persisted, upsertedRecipient)
: [];
// Handle recipient updated audit log.
if (recipient._persisted && changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user,
requestMetadata,
data: {
changes,
...baseAuditLog,
},
}),
});
}
// Handle recipient created audit log.
if (!recipient._persisted) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
documentId: documentId,
user,
requestMetadata,
data: baseAuditLog,
}),
});
}
return upsertedRecipient;
}),
),
);
);
});
if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
await prisma.$transaction(async (tx) => {
await tx.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
},
},
},
});
await tx.documentAuditLog.createMany({
data: removedRecipients.map((recipient) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
documentId: documentId,
user,
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
}),
),
});
});
}

View File

@@ -20,7 +20,20 @@ export const setRecipientsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

View File

@@ -0,0 +1,63 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
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,
},
include: {
team: {
include: {
subscription: true,
},
},
},
});
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: {
teamId: teamMemberInvite.teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
});
};

View File

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

View File

@@ -0,0 +1,52 @@
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
import { WEBAPP_BASE_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: `${WEBAPP_BASE_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, 'Something went wrong.');
}
};

View File

@@ -0,0 +1,132 @@
import { createElement } from 'react';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { WEBAPP_BASE_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';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
export type CreateTeamEmailVerificationOptions = {
userId: number;
teamId: number;
data: {
email: string;
name: string;
};
};
export const createTeamEmailVerification = async ({
userId,
teamId,
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'],
},
},
},
},
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.',
);
}
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
});
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
});
} 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('email')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
}
throw err;
}
};
/**
* Send an email to a user asking them to accept a team email request.
*
* @param email The email address to use for the team.
* @param token The token used to authenticate that the user has granted access.
* @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,
teamName: string,
teamUrl: string,
) => {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(ConfirmTeamEmailTemplate, {
assetBaseUrl,
baseUrl: WEBAPP_BASE_URL,
teamName,
teamUrl,
token,
});
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@@ -0,0 +1,161 @@
import { createElement } from 'react';
import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
import { WEBAPP_BASE_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 { TeamMemberInviteStatus } from '@documenso/prisma/client';
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
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) => {
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,
},
});
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, '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,
'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,
teamName: team.name,
teamUrl: team.url,
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',
'Failed to send invite emails to one or more users.',
`Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
);
}
};
type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
email: string;
};
/**
* Send an email to a user inviting them to join a team.
*/
export const sendTeamMemberInviteEmail = async ({
email,
...emailTemplateOptions
}: SendTeamMemberInviteEmailOptions) => {
const template = createElement(TeamInviteEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
...emailTemplateOptions,
});
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@@ -0,0 +1,207 @@
import type Stripe from 'stripe';
import { z } from 'zod';
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { Prisma, TeamMemberRole } from '@documenso/prisma/client';
import { stripe } from '../stripe';
export type CreateTeamOptions = {
/**
* ID of the user creating the Team.
*/
userId: number;
/**
* Name of the team to display.
*/
teamName: string;
/**
* Unique URL of the team.
*
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
*/
teamUrl: string;
};
export type CreateTeamResponse =
| {
paymentRequired: false;
}
| {
paymentRequired: true;
pendingTeamId: number;
};
/**
* 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<CreateTeamResponse> => {
const user = await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
include: {
Subscription: true,
},
});
let isPaymentRequired = IS_BILLING_ENABLED;
let customerId: string | null = null;
if (IS_BILLING_ENABLED) {
const communityPlanPriceIds = await getCommunityPlanPriceIds();
isPaymentRequired = !subscriptionsContainsActiveCommunityPlan(
user.Subscription,
communityPlanPriceIds,
);
customerId = await createTeamCustomer({
name: user.name ?? teamName,
email: user.email,
}).then((customer) => customer.id);
}
try {
// Create the team directly if no payment is required.
if (!isPaymentRequired) {
await prisma.team.create({
data: {
name: teamName,
url: teamUrl,
ownerUserId: user.id,
customerId,
members: {
create: [
{
userId,
role: TeamMemberRole.ADMIN,
},
],
},
},
});
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,
},
});
if (existingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
}
if (!customerId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, '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, 'Team URL already exists.');
}
throw err;
}
};
export type CreateTeamFromPendingTeamOptions = {
pendingTeamId: number;
subscription: Stripe.Subscription;
};
export const createTeamFromPendingTeam = async ({
pendingTeamId,
subscription,
}: CreateTeamFromPendingTeamOptions) => {
return 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.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
);
// Attach the team ID to the subscription metadata for sanity reasons.
await stripe.subscriptions
.update(subscription.id, {
metadata: {
teamId: team.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 team;
});
};

View File

@@ -0,0 +1,34 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
export type DeleteTeamEmailVerificationOptions = {
userId: number;
teamId: number;
};
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 tx.teamEmailVerification.delete({
where: {
teamId,
},
});
});
};

View File

@@ -0,0 +1,93 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { WEBAPP_BASE_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';
export type DeleteTeamEmailOptions = {
userId: number;
userEmail: string;
teamId: number;
};
/**
* Delete a team email.
*
* 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,
},
},
},
});
await tx.teamEmail.delete({
where: {
teamId,
},
});
return foundTeam;
});
try {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(TeamEmailRemovedTemplate, {
assetBaseUrl,
baseUrl: WEBAPP_BASE_URL,
teamEmail: team.teamEmail?.email ?? '',
teamName: team.name,
teamUrl: team.url,
});
await mailer.sendMail({
to: {
address: team.owner.email,
name: team.owner.name ?? '',
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team email has been revoked for ${team.name}`,
html: render(template),
text: render(template, { plainText: true }),
});
} catch (e) {
// Todo: Teams - Alert us.
// We don't want to prevent a user from revoking access because an email could not be sent.
}
};

View File

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

View File

@@ -0,0 +1,102 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
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';
export type DeleteTeamMembersOptions = {
/**
* 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 team members to remove.
*/
teamMemberIds: number[];
};
export const deleteTeamMembers = async ({
userId,
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'],
},
},
},
},
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,
},
},
});
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,
});
}
});
};

View File

@@ -0,0 +1,15 @@
import { prisma } from '@documenso/prisma';
export type DeleteTeamPendingOptions = {
userId: number;
pendingTeamId: number;
};
export const deleteTeamPending = async ({ userId, pendingTeamId }: DeleteTeamPendingOptions) => {
await prisma.teamPending.delete({
where: {
id: pendingTeamId,
ownerUserId: userId,
},
});
};

View File

@@ -0,0 +1,42 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type DeleteTeamTransferRequestOptions = {
/**
* The ID of the user deleting the transfer.
*/
userId: number;
/**
* The ID of the team whose team transfer request should be deleted.
*/
teamId: number;
};
export const deleteTeamTransferRequest = async ({
userId,
teamId,
}: DeleteTeamTransferRequestOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_TRANSFER_REQUEST'],
},
},
},
},
});
await tx.teamTransferVerification.delete({
where: {
teamId,
},
});
});
};

View File

@@ -0,0 +1,42 @@
import { prisma } from '@documenso/prisma';
import { AppError } from '../../errors/app-error';
import { stripe } from '../stripe';
export type DeleteTeamOptions = {
userId: number;
teamId: number;
};
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,
},
});
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,
},
});
});
};

View File

@@ -0,0 +1,52 @@
import { getInvoices } from '@documenso/ee/server-only/stripe/get-invoices';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export interface FindTeamInvoicesOptions {
userId: number;
teamId: number;
}
export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => {
const team = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
if (!team.customerId) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team has no customer ID.');
}
const results = await getInvoices({ customerId: team.customerId });
if (!results) {
return null;
}
return {
...results,
data: results.data.map((invoice) => ({
invoicePdf: invoice.invoice_pdf,
hostedInvoicePdf: invoice.hosted_invoice_url,
status: invoice.status,
subtotal: invoice.subtotal,
total: invoice.total,
amountPaid: invoice.amount_paid,
amountDue: invoice.amount_due,
created: invoice.created,
paid: invoice.paid,
quantity: invoice.lines.data[0].quantity ?? 0,
currency: invoice.currency,
})),
};
};

View File

@@ -0,0 +1,91 @@
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { TeamMemberInvite } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import type { FindResultSet } from '../../types/find-result-set';
export interface FindTeamMemberInvitesOptions {
userId: number;
teamId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof TeamMemberInvite;
direction: 'asc' | 'desc';
};
}
export const findTeamMemberInvites = async ({
userId,
teamId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamMemberInvitesOptions) => {
const orderByColumn = orderBy?.column ?? 'email';
const orderByDirection = orderBy?.direction ?? 'desc';
// Check that the user belongs to the team they are trying to find invites in.
const userTeam = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(term)
.with(P.string.minLength(1), () => ({
email: {
contains: term,
mode: Prisma.QueryMode.insensitive,
},
}))
.otherwise(() => undefined);
const whereClause: Prisma.TeamMemberInviteWhereInput = {
...termFilters,
teamId: userTeam.id,
};
const [data, count] = await Promise.all([
prisma.teamMemberInvite.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
// Exclude token attribute.
select: {
id: true,
teamId: true,
email: true,
role: true,
createdAt: true,
},
}),
prisma.teamMemberInvite.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
};

View File

@@ -0,0 +1,100 @@
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { TeamMember } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import type { FindResultSet } from '../../types/find-result-set';
export interface FindTeamMembersOptions {
userId: number;
teamId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof TeamMember | 'name';
direction: 'asc' | 'desc';
};
}
export const findTeamMembers = async ({
userId,
teamId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamMembersOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
// Check that the user belongs to the team they are trying to find members in.
const userTeam = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
const termFilters: Prisma.TeamMemberWhereInput | undefined = match(term)
.with(P.string.minLength(1), () => ({
user: {
name: {
contains: term,
mode: Prisma.QueryMode.insensitive,
},
},
}))
.otherwise(() => undefined);
const whereClause: Prisma.TeamMemberWhereInput = {
...termFilters,
teamId: userTeam.id,
};
let orderByClause: Prisma.TeamMemberOrderByWithRelationInput = {
[orderByColumn]: orderByDirection,
};
// Name field is nested in the user so we have to handle it differently.
if (orderByColumn === 'name') {
orderByClause = {
user: {
name: orderByDirection,
},
};
}
const [data, count] = await Promise.all([
prisma.teamMember.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: orderByClause,
include: {
user: {
select: {
name: true,
email: true,
},
},
},
}),
prisma.teamMember.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
};

View File

@@ -0,0 +1,58 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
export interface FindTeamsPendingOptions {
userId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Team;
direction: 'asc' | 'desc';
};
}
export const findTeamsPending = async ({
userId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamsPendingOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamPendingWhereInput = {
ownerUserId: userId,
};
if (term && term.length > 0) {
whereClause.name = {
contains: term,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.teamPending.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
}),
prisma.teamPending.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
};

View File

@@ -0,0 +1,76 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
export interface FindTeamsOptions {
userId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Team;
direction: 'asc' | 'desc';
};
}
export const findTeams = async ({
userId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamsOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamWhereInput = {
members: {
some: {
userId,
},
},
};
if (term && term.length > 0) {
whereClause.name = {
contains: term,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.team.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
include: {
members: {
where: {
userId,
},
},
},
}),
prisma.team.count({
where: whereClause,
}),
]);
const maskedData = data.map((team) => ({
...team,
currentTeamMember: team.members[0],
members: undefined,
}));
return {
data: maskedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof maskedData>;
};

View File

@@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export type GetTeamEmailByEmailOptions = {
email: string;
};
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
return await prisma.teamEmail.findFirst({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};

View File

@@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export type GetTeamInvitationsOptions = {
email: string;
};
export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) => {
return await prisma.teamMemberInvite.findMany({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};

View File

@@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
export type GetTeamMembersOptions = {
userId: number;
teamId: number;
};
/**
* Get all team members for a given team.
*/
export const getTeamMembers = async ({ userId, teamId }: GetTeamMembersOptions) => {
return await prisma.teamMember.findMany({
where: {
team: {
id: teamId,
members: {
some: {
userId: userId,
},
},
},
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
};

View File

@@ -0,0 +1,107 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export type GetTeamByIdOptions = {
userId?: number;
teamId: number;
};
/**
* Get a team given a teamId.
*
* Provide an optional userId to check that the user is a member of the team.
*/
export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
id: teamId,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findUniqueOrThrow({
where: whereFilter,
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
const { members, ...team } = result;
return {
...team,
currentTeamMember: userId !== undefined ? members[0] : null,
};
};
export type GetTeamByUrlOptions = {
userId: number;
teamUrl: string;
};
/**
* Get a team given a team URL.
*/
export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
url: teamUrl,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findUniqueOrThrow({
where: whereFilter,
include: {
teamEmail: true,
emailVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
transferVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
subscription: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
const { members, ...team } = result;
return {
...team,
currentTeamMember: members[0],
};
};

View File

@@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
export type GetTeamsOptions = {
userId: number;
};
export type GetTeamsResponse = Awaited<ReturnType<typeof getTeams>>;
export const getTeams = async ({ userId }: GetTeamsOptions) => {
const teams = await prisma.team.findMany({
where: {
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
return teams.map(({ members, ...team }) => ({
...team,
currentTeamMember: members[0],
}));
};

View File

@@ -0,0 +1,59 @@
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';
export type LeaveTeamOptions = {
/**
* The ID of the user who is leaving the team.
*/
userId: number;
/**
* The ID of the team the user is leaving.
*/
teamId: number;
};
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: {
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,
});
}
});
};

View File

@@ -0,0 +1,106 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
export type RequestTeamOwnershipTransferOptions = {
/**
* The ID of the user initiating the transfer.
*/
userId: number;
/**
* The name of the user initiating the transfer.
*/
userName: string;
/**
* The ID of the team whose ownership is being transferred.
*/
teamId: number;
/**
* The user ID of the new owner.
*/
newOwnerUserId: number;
/**
* Whether to clear any current payment methods attached to the team.
*/
clearPaymentMethods: boolean;
};
export const requestTeamOwnershipTransfer = async ({
userId,
userName,
teamId,
newOwnerUserId,
}: RequestTeamOwnershipTransferOptions) => {
// 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,
},
},
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
});
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: {
teamId,
},
create: teamVerificationPayload,
update: teamVerificationPayload,
});
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 }),
});
});
};

View File

@@ -0,0 +1,65 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError } from '@documenso/lib/errors/app-error';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { sendTeamEmailVerificationEmail } from './create-team-email-verification';
export type ResendTeamMemberInvitationOptions = {
userId: number;
teamId: number;
};
/**
* Resend a team email verification with a new token.
*/
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'],
},
},
},
},
include: {
emailVerification: true,
},
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
}
const { emailVerification } = team;
if (!emailVerification) {
throw new AppError(
'VerificationNotFound',
'No team email verification exists for this team.',
);
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
});
};

View File

@@ -0,0 +1,76 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { sendTeamMemberInviteEmail } from './create-team-member-invites';
export type ResendTeamMemberInvitationOptions = {
/**
* The ID of the user who is initiating this action.
*/
userId: number;
/**
* The name of the user who is initiating this action.
*/
userName: string;
/**
* The ID of the team.
*/
teamId: number;
/**
* The IDs of the invitations to resend.
*/
invitationId: number;
};
/**
* Resend an email for a given team member invite.
*/
export const resendTeamMemberInvitation = async ({
userId,
userName,
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'],
},
},
},
},
});
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,
},
});
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,
});
});
};

View File

@@ -0,0 +1,88 @@
import type Stripe from 'stripe';
import { transferTeamSubscription } from '@documenso/ee/server-only/stripe/transfer-team-subscription';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type TransferTeamOwnershipOptions = {
token: string;
};
export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => {
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),
);
}
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,
},
},
},
},
});
});
};

View File

@@ -0,0 +1,42 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type UpdateTeamEmailOptions = {
userId: number;
teamId: number;
data: {
name: string;
};
};
export const updateTeamEmail = async ({ userId, teamId, data }: UpdateTeamEmailOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
teamEmail: {
isNot: null,
},
},
});
await tx.teamEmail.update({
where: {
teamId,
},
data: {
// Note: Never allow the email to be updated without re-verifying via email.
name: data.name,
},
});
});
};

View File

@@ -0,0 +1,92 @@
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 { TeamMemberRole } from '@documenso/prisma/client';
export type UpdateTeamMemberOptions = {
userId: number;
teamId: number;
teamMemberId: number;
data: {
role: TeamMemberRole;
};
};
export const updateTeamMember = async ({
userId,
teamId,
teamMemberId,
data,
}: UpdateTeamMemberOptions) => {
await prisma.$transaction(async (tx) => {
// Find the team and validate that the user is allowed to update 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,
},
},
},
});
const currentTeamMember = team.members.find((member) => member.userId === userId);
const teamMemberToUpdate = team.members.find((member) => member.id === teamMemberId);
if (!teamMemberToUpdate || !currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member does not exist');
}
if (teamMemberToUpdate.userId === team.ownerUserId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update the owner');
}
const isMemberToUpdateHigherRole = !isTeamRoleWithinUserHierarchy(
currentTeamMember.role,
teamMemberToUpdate.role,
);
if (isMemberToUpdateHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update a member with a higher role');
}
const isNewMemberRoleHigherThanCurrentRole = !isTeamRoleWithinUserHierarchy(
currentTeamMember.role,
data.role,
);
if (isNewMemberRoleHigherThanCurrentRole) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'Cannot give a member a role higher than the user initating the update',
);
}
return await tx.teamMember.update({
where: {
id: teamMemberId,
teamId,
userId: {
not: team.ownerUserId,
},
},
data: {
role: data.role,
},
});
});
};

View File

@@ -0,0 +1,65 @@
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
export type UpdateTeamOptions = {
userId: number;
teamId: number;
data: {
name?: string;
url?: string;
};
};
export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) => {
try {
await prisma.$transaction(async (tx) => {
const foundPendingTeamWithUrl = await tx.teamPending.findFirst({
where: {
url: data.url,
},
});
if (foundPendingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
}
const team = await tx.team.update({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
data: {
url: data.url,
name: data.name,
},
});
return team;
});
} 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, 'Team URL already exists.');
}
throw err;
}
};

View File

@@ -11,7 +11,23 @@ export const createDocumentFromTemplate = async ({
userId,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
where: { id: templateId, userId },
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
Recipient: true,
Field: true,
@@ -34,6 +50,7 @@ export const createDocumentFromTemplate = async ({
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {

View File

@@ -1,20 +1,36 @@
import { prisma } from '@documenso/prisma';
import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
userId: number;
teamId?: number;
};
export const createTemplate = async ({
title,
userId,
teamId,
templateDocumentDataId,
}: CreateTemplateOptions) => {
if (teamId) {
await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return await prisma.template.create({
data: {
title,
userId,
templateDocumentDataId,
teamId,
},
});
};

View File

@@ -8,5 +8,23 @@ export type DeleteTemplateOptions = {
};
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
return await prisma.template.delete({ where: { id, userId } });
return await prisma.template.delete({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
};

View File

@@ -1,14 +1,39 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
userId: number;
};
export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => {
export const duplicateTemplate = async ({
templateId,
userId,
teamId,
}: DuplicateTemplateOptions) => {
let templateWhereFilter: Prisma.TemplateWhereUniqueInput = {
id: templateId,
userId,
teamId: null,
};
if (teamId !== undefined) {
templateWhereFilter = {
id: templateId,
teamId,
team: {
members: {
some: {
userId,
},
},
},
};
}
const template = await prisma.template.findUnique({
where: { id: templateId, userId },
where: templateWhereFilter,
include: {
Recipient: true,
Field: true,
@@ -31,6 +56,7 @@ export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplat
const duplicatedTemplate = await prisma.template.create({
data: {
userId,
teamId,
title: template.title + ' (copy)',
templateDocumentDataId: documentData.id,
Recipient: {

View File

@@ -0,0 +1,56 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export type FindTemplatesOptions = {
userId: number;
teamId?: number;
page: number;
perPage: number;
};
export const findTemplates = async ({
userId,
teamId,
page = 1,
perPage = 10,
}: FindTemplatesOptions) => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
};
if (teamId !== undefined) {
whereFilter = {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
};
}
const [templates, count] = await Promise.all([
prisma.template.findMany({
where: whereFilter,
include: {
templateDocumentData: true,
Field: true,
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.template.count({
where: whereFilter,
}),
]);
return {
templates,
totalPages: Math.ceil(count / perPage),
};
};

View File

@@ -1,4 +1,5 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export interface GetTemplateByIdOptions {
id: number;
@@ -6,11 +7,26 @@ export interface GetTemplateByIdOptions {
}
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
const whereFilter: Prisma.TemplateWhereInput = {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
};
return await prisma.template.findFirstOrThrow({
where: {
id,
userId,
},
where: whereFilter,
include: {
templateDocumentData: true,
},

View File

@@ -1,35 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetTemplatesOptions = {
userId: number;
page: number;
perPage: number;
};
export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => {
const [templates, count] = await Promise.all([
prisma.template.findMany({
where: {
userId,
},
include: {
templateDocumentData: true,
Field: true,
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.template.count({
where: {
userId,
},
}),
]);
return {
templates,
totalPages: Math.ceil(count / perPage),
};
};

View File

@@ -1,11 +1,12 @@
import { hash } from 'bcrypt';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { IdentityProvider } from '@documenso/prisma/client';
import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth';
import { getFlag } from '../../universal/get-feature-flag';
export interface CreateUserOptions {
name: string;
@@ -15,8 +16,6 @@ export interface CreateUserOptions {
}
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
const isBillingEnabled = await getFlag('app_billing');
const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
@@ -29,7 +28,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
throw new Error('User already exists');
}
let user = await prisma.user.create({
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
@@ -39,12 +38,81 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
},
});
if (isBillingEnabled) {
const acceptedTeamInvites = await prisma.teamMemberInvite.findMany({
where: {
email: {
equals: email,
mode: Prisma.QueryMode.insensitive,
},
status: TeamMemberInviteStatus.ACCEPTED,
},
});
// For each team invite, add the user to the team and delete the team invite.
// If an error occurs, reset the invitation to not accepted.
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,
},
},
subscription: true,
},
});
if (team.subscription) {
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: team.members.length,
});
}
})
.catch(async () => {
await prisma.teamMemberInvite.update({
where: {
id: invite.id,
},
data: {
status: TeamMemberInviteStatus.PENDING,
},
});
}),
),
);
// Update the user record with a new or existing Stripe customer record.
if (IS_BILLING_ENABLED) {
try {
const stripeSession = await getStripeCustomerByUser(user);
user = stripeSession.user;
} catch (e) {
console.error(e);
return await getStripeCustomerByUser(user).then((session) => session.user);
} catch (err) {
console.error(err);
}
}

View File

@@ -0,0 +1,18 @@
import { prisma } from '@documenso/prisma';
export type GetMostRecentVerificationTokenByUserIdOptions = {
userId: number;
};
export const getMostRecentVerificationTokenByUserId = async ({
userId,
}: GetMostRecentVerificationTokenByUserIdOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@@ -1,13 +1,20 @@
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
const IDENTIFIER = 'confirmation-email';
export const sendConfirmationToken = async ({ email }: { email: string }) => {
type SendConfirmationTokenOptions = { email: string; force?: boolean };
export const sendConfirmationToken = async ({
email,
force = false,
}: SendConfirmationTokenOptions) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
@@ -20,6 +27,21 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
throw new Error('User not found');
}
if (user.emailVerified) {
throw new Error('Email verified');
}
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
// If we've sent a token in the last 5 minutes, don't send another one
if (
!force &&
mostRecentToken?.createdAt &&
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
@@ -37,5 +59,11 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
try {
await sendConfirmationEmail({ userId: user.id });
return { success: true };
} catch (err) {
throw new Error(`Failed to send the confirmation email`);
}
};