mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
Merge branch 'main' into feat/add-runtime-env
This commit is contained in:
@ -1,21 +1,25 @@
|
||||
import { compare } from 'bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
user,
|
||||
password,
|
||||
requestMetadata,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
@ -33,15 +37,26 @@ export const disableTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
|
||||
type EnableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
code: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const enableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
code,
|
||||
requestMetadata,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
@ -32,13 +35,24 @@ export const enableTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
const updatedUser = await prisma.$transaction(async (tx) => {
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_ENABLE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { type User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -24,7 +24,20 @@ export const upsertDocumentMeta = async ({
|
||||
await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -5,15 +5,37 @@ import { prisma } from '@documenso/prisma';
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentDataId: string;
|
||||
};
|
||||
|
||||
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,
|
||||
}: CreateDocumentOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (teamId !== undefined) {
|
||||
await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
@ -33,7 +44,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
|
||||
},
|
||||
});
|
||||
|
||||
const createdDocument = await prisma.document.create({
|
||||
const createDocumentArguments: Prisma.DocumentCreateArgs = {
|
||||
data: {
|
||||
title: document.title,
|
||||
User: {
|
||||
@ -53,7 +64,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;
|
||||
};
|
||||
|
||||
@ -2,15 +2,18 @@ import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, Prisma } from '@documenso/prisma/client';
|
||||
import { SigningStatus } 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';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
term?: string;
|
||||
status?: ExtendedDocumentStatus;
|
||||
page?: number;
|
||||
@ -19,22 +22,50 @@ export type FindDocumentsOptions = {
|
||||
column: keyof Omit<Document, 'document'>;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
period?: '' | '7d' | '14d' | '30d';
|
||||
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';
|
||||
@ -51,90 +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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
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) {
|
||||
@ -147,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,
|
||||
@ -164,13 +145,16 @@ export const findDocuments = async ({
|
||||
},
|
||||
},
|
||||
Recipient: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
where: {
|
||||
...termFilters,
|
||||
...filters,
|
||||
},
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -189,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();
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -1,76 +1,34 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
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';
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: User;
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
period?: PeriodSelectorValue;
|
||||
};
|
||||
|
||||
export const getStats = async ({ user }: GetStatsInput) => {
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
User: {
|
||||
email: {
|
||||
not: user.email,
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
|
||||
? getTeamCounts({ ...options.team, createdAt })
|
||||
: getCounts({ user, createdAt }));
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
@ -106,3 +64,189 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
||||
|
||||
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: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
createdAt,
|
||||
teamId: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
createdAt,
|
||||
User: {
|
||||
email: {
|
||||
not: user.email,
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
let hasSignedCountsGroupByArgs = null;
|
||||
|
||||
if (teamEmail) {
|
||||
ownerCountsWhereInput = {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
OR: [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: ownerCountsWhereInput,
|
||||
}),
|
||||
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
|
||||
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
|
||||
]);
|
||||
};
|
||||
|
||||
@ -6,7 +6,11 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
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';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
@ -14,20 +18,29 @@ export type ResendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
recipients: number[];
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => {
|
||||
export const resendDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
recipients,
|
||||
teamId,
|
||||
}: 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: {
|
||||
@ -61,6 +74,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
const customEmailTemplate = {
|
||||
@ -79,8 +96,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
role: recipient.role,
|
||||
});
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
@ -92,7 +112,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: 'Please sign this document',
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
@ -25,7 +27,20 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
@ -49,6 +64,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
const customEmailTemplate = {
|
||||
@ -71,8 +90,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
role: recipient.role,
|
||||
});
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
@ -84,7 +106,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: 'Please sign this document',
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
@ -12,7 +12,20 @@ export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOpti
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
title,
|
||||
|
||||
@ -10,7 +10,20 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
|
||||
where: {
|
||||
documentId,
|
||||
Document: {
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
|
||||
@ -10,7 +10,20 @@ export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForT
|
||||
where: {
|
||||
templateId,
|
||||
Template: {
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
|
||||
@ -25,7 +25,20 @@ export const setFieldsForDocument = async ({
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -43,11 +56,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
|
||||
|
||||
@ -27,7 +27,20 @@ export const setFieldsForTemplate = async ({
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -13,7 +13,20 @@ export const getRecipientsForDocument = async ({
|
||||
where: {
|
||||
documentId,
|
||||
Document: {
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
|
||||
@ -13,7 +13,20 @@ export const getRecipientsForTemplate = async ({
|
||||
where: {
|
||||
templateId,
|
||||
Template: {
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { nanoid } from '../../universal/id';
|
||||
@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions {
|
||||
id?: number | null;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
}[];
|
||||
}
|
||||
|
||||
@ -21,7 +23,20 @@ export const setRecipientsForDocument = async ({
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -79,13 +94,20 @@ export const setRecipientsForDocument = async ({
|
||||
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,
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@ -20,7 +20,20 @@ export const setRecipientsForTemplate = async ({
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
63
packages/lib/server-only/team/accept-team-invitation.ts
Normal file
63
packages/lib/server-only/team/accept-team-invitation.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
47
packages/lib/server-only/team/create-team-billing-portal.ts
Normal file
47
packages/lib/server-only/team/create-team-billing-portal.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@ -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.');
|
||||
}
|
||||
};
|
||||
132
packages/lib/server-only/team/create-team-email-verification.ts
Normal file
132
packages/lib/server-only/team/create-team-email-verification.ts
Normal 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 }),
|
||||
});
|
||||
};
|
||||
161
packages/lib/server-only/team/create-team-member-invites.ts
Normal file
161
packages/lib/server-only/team/create-team-member-invites.ts
Normal 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 }),
|
||||
});
|
||||
};
|
||||
207
packages/lib/server-only/team/create-team.ts
Normal file
207
packages/lib/server-only/team/create-team.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
93
packages/lib/server-only/team/delete-team-email.ts
Normal file
93
packages/lib/server-only/team/delete-team-email.ts
Normal 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.
|
||||
}
|
||||
};
|
||||
47
packages/lib/server-only/team/delete-team-invitations.ts
Normal file
47
packages/lib/server-only/team/delete-team-invitations.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
102
packages/lib/server-only/team/delete-team-members.ts
Normal file
102
packages/lib/server-only/team/delete-team-members.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
15
packages/lib/server-only/team/delete-team-pending.ts
Normal file
15
packages/lib/server-only/team/delete-team-pending.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
42
packages/lib/server-only/team/delete-team.ts
Normal file
42
packages/lib/server-only/team/delete-team.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
52
packages/lib/server-only/team/find-team-invoices.ts
Normal file
52
packages/lib/server-only/team/find-team-invoices.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
91
packages/lib/server-only/team/find-team-member-invites.ts
Normal file
91
packages/lib/server-only/team/find-team-member-invites.ts
Normal 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>;
|
||||
};
|
||||
100
packages/lib/server-only/team/find-team-members.ts
Normal file
100
packages/lib/server-only/team/find-team-members.ts
Normal 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>;
|
||||
};
|
||||
58
packages/lib/server-only/team/find-teams-pending.ts
Normal file
58
packages/lib/server-only/team/find-teams-pending.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
76
packages/lib/server-only/team/find-teams.ts
Normal file
76
packages/lib/server-only/team/find-teams.ts
Normal 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>;
|
||||
};
|
||||
22
packages/lib/server-only/team/get-team-email-by-email.ts
Normal file
22
packages/lib/server-only/team/get-team-email-by-email.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
22
packages/lib/server-only/team/get-team-invitations.ts
Normal file
22
packages/lib/server-only/team/get-team-invitations.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
33
packages/lib/server-only/team/get-team-members.ts
Normal file
33
packages/lib/server-only/team/get-team-members.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
95
packages/lib/server-only/team/get-team.ts
Normal file
95
packages/lib/server-only/team/get-team.ts
Normal file
@ -0,0 +1,95 @@
|
||||
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: true,
|
||||
transferVerification: true,
|
||||
subscription: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { members, ...team } = result;
|
||||
|
||||
return {
|
||||
...team,
|
||||
currentTeamMember: members[0],
|
||||
};
|
||||
};
|
||||
33
packages/lib/server-only/team/get-teams.ts
Normal file
33
packages/lib/server-only/team/get-teams.ts
Normal 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],
|
||||
}));
|
||||
};
|
||||
59
packages/lib/server-only/team/leave-team.ts
Normal file
59
packages/lib/server-only/team/leave-team.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
106
packages/lib/server-only/team/request-team-ownership-transfer.ts
Normal file
106
packages/lib/server-only/team/request-team-ownership-transfer.ts
Normal 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 }),
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
88
packages/lib/server-only/team/transfer-team-ownership.ts
Normal file
88
packages/lib/server-only/team/transfer-team-ownership.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
42
packages/lib/server-only/team/update-team-email.ts
Normal file
42
packages/lib/server-only/team/update-team-email.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
92
packages/lib/server-only/team/update-team-member.ts
Normal file
92
packages/lib/server-only/team/update-team-member.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
65
packages/lib/server-only/team/update-team.ts
Normal file
65
packages/lib/server-only/team/update-team.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
56
packages/lib/server-only/template/find-templates.ts
Normal file
56
packages/lib/server-only/template/find-templates.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { UserSecurityAuditLog, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
export type FindUserSecurityAuditLogsOptions = {
|
||||
userId: number;
|
||||
type?: UserSecurityAuditLogType;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Omit<UserSecurityAuditLog, 'id' | 'userId'>;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
};
|
||||
|
||||
export const findUserSecurityAuditLogs = async ({
|
||||
userId,
|
||||
type,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindUserSecurityAuditLogsOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const whereClause = {
|
||||
userId,
|
||||
type,
|
||||
};
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.userSecurityAuditLog.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
}),
|
||||
prisma.userSecurityAuditLog.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
};
|
||||
@ -1,16 +1,19 @@
|
||||
import { compare, hash } from 'bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { sendResetPassword } from '../auth/send-reset-password';
|
||||
|
||||
export type ResetPasswordOptions = {
|
||||
token: string;
|
||||
password: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const resetPassword = async ({ token, password }: ResetPasswordOptions) => {
|
||||
export const resetPassword = async ({ token, password, requestMetadata }: ResetPasswordOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Invalid token provided. Please try again.');
|
||||
}
|
||||
@ -56,6 +59,14 @@ export const resetPassword = async ({ token, password }: ResetPasswordOptions) =
|
||||
userId: foundToken.userId,
|
||||
},
|
||||
}),
|
||||
prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: foundToken.userId,
|
||||
type: UserSecurityAuditLogType.PASSWORD_RESET,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await sendResetPassword({ userId: foundToken.userId });
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
import { compare, hash } from 'bcrypt';
|
||||
|
||||
import { SALT_ROUNDS } from '@documenso/lib/constants/auth';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdatePasswordOptions = {
|
||||
userId: number;
|
||||
password: string;
|
||||
currentPassword: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const updatePassword = async ({
|
||||
userId,
|
||||
password,
|
||||
currentPassword,
|
||||
requestMetadata,
|
||||
}: UpdatePasswordOptions) => {
|
||||
// Existence check
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -39,14 +42,23 @@ export const updatePassword = async ({
|
||||
|
||||
const hashedNewPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedNewPassword,
|
||||
},
|
||||
});
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
type: UserSecurityAuditLogType.PASSWORD_UPDATE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedNewPassword,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
|
||||
export type UpdateProfileOptions = {
|
||||
userId: number;
|
||||
name: string;
|
||||
signature: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const updateProfile = async ({ userId, name, signature }: UpdateProfileOptions) => {
|
||||
export const updateProfile = async ({
|
||||
userId,
|
||||
name,
|
||||
signature,
|
||||
requestMetadata,
|
||||
}: UpdateProfileOptions) => {
|
||||
// Existence check
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@ -14,15 +23,24 @@ export const updateProfile = async ({ userId, name, signature }: UpdateProfileOp
|
||||
},
|
||||
});
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
signature,
|
||||
},
|
||||
});
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
type: UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
signature,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user