chore: merged main

This commit is contained in:
Catalin Documenso
2025-05-07 11:17:15 +03:00
150 changed files with 14851 additions and 616 deletions

View File

@ -3,22 +3,38 @@ import type { DocumentData } from '@prisma/client';
import { getFile } from '../universal/upload/get-file';
import { downloadFile } from './download-file';
type DocumentVersion = 'original' | 'signed';
type DownloadPDFProps = {
documentData: DocumentData;
fileName?: string;
/**
* Specifies which version of the document to download.
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version.
*/
version?: DocumentVersion;
};
export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => {
const bytes = await getFile(documentData);
export const downloadPDF = async ({
documentData,
fileName,
version = 'signed',
}: DownloadPDFProps) => {
const bytes = await getFile({
type: documentData.type,
data: version === 'signed' ? documentData.data : documentData.initialData,
});
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
downloadFile({
filename: `${baseTitle}_signed.pdf`,
filename: `${baseTitle}${suffix}`,
data: blob,
});
};

View File

@ -17,6 +17,8 @@ export const VALID_DATE_FORMAT_VALUES = [
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
] as const;
export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
export const DATE_FORMATS = [
{
key: 'yyyy-MM-dd_hh:mm_a',
@ -94,3 +96,7 @@ export const convertToLocalSystemFormat = (
return formattedDate;
};
export const isValidDateFormat = (dateFormat: unknown): dateFormat is ValidDateFormat => {
return VALID_DATE_FORMAT_VALUES.includes(dateFormat as ValidDateFormat);
};

View File

@ -25,6 +25,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
allowEmbeddedAuthoring: z.boolean(),
})
.nullish(),
}),

View File

@ -22,6 +22,7 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { prefixedId } from '../../../universal/id';
import { getFileServerSide } from '../../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
@ -130,6 +131,17 @@ export const run = async ({
documentData.data = documentData.initialData;
}
if (!document.qrToken) {
await prisma.document.update({
where: {
id: document.id,
},
data: {
qrToken: prefixedId('qr'),
},
});
}
const pdfData = await getFileServerSide(documentData);
const certificateData =

View File

@ -13,7 +13,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
@ -142,6 +142,7 @@ export const createDocumentV2 = async ({
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId: data.externalId,
documentDataId,
userId,
@ -232,6 +233,7 @@ export const createDocumentV2 = async ({
documentMeta: true,
recipients: true,
fields: true,
folder: true,
},
});

View File

@ -1,5 +1,5 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { DocumentVisibility, Team, TeamGlobalSettings } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@ -13,6 +13,7 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { determineDocumentVisibility } from '../../utils/document-visibility';
@ -28,6 +29,7 @@ export type CreateDocumentOptions = {
normalizePdf?: boolean;
timezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
};
export const createDocument = async ({
@ -40,6 +42,7 @@ export const createDocument = async ({
formValues,
requestMetadata,
timezone,
folderId,
}: CreateDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -88,6 +91,29 @@ export const createDocument = async ({
userTeamRole = teamWithUserRole.members[0]?.role;
}
let folderVisibility: DocumentVisibility | undefined;
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
select: {
visibility: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
folderVisibility = folder.visibility;
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
@ -115,14 +141,18 @@ export const createDocument = async ({
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId,
documentDataId,
userId,
teamId,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
folderId,
visibility:
folderVisibility ??
determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {

View File

@ -3,6 +3,7 @@ import { DocumentSource, type Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { prefixedId } from '../../universal/id';
import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentOptions {
@ -56,6 +57,7 @@ export const duplicateDocument = async ({
const createDocumentArguments: Prisma.DocumentCreateArgs = {
data: {
title: document.title,
qrToken: prefixedId('qr'),
user: {
connect: {
id: document.userId,

View File

@ -27,6 +27,7 @@ export type FindDocumentsOptions = {
period?: PeriodSelectorValue;
senderIds?: number[];
query?: string;
folderId?: string;
};
export const findDocuments = async ({
@ -41,6 +42,7 @@ export const findDocuments = async ({
period,
senderIds,
query = '',
folderId,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -120,10 +122,10 @@ export const findDocuments = async ({
},
];
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
}
if (filters === null) {
@ -227,6 +229,12 @@ export const findDocuments = async ({
};
}
if (folderId !== undefined) {
whereClause.folderId = folderId;
} else {
whereClause.folderId = null;
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: whereClause,
@ -273,13 +281,18 @@ export const findDocuments = async ({
} satisfies FindResultResponse<typeof data>;
};
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: User,
folderId?: string | null,
) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
OR: [
{
userId: user.id,
teamId: null,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@ -288,6 +301,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
{
status: ExtendedDocumentStatus.PENDING,
@ -296,6 +310,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
],
}))
@ -324,6 +339,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.PENDING,
@ -336,6 +352,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
},
},
folderId: folderId,
},
],
}))
@ -345,6 +362,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@ -353,6 +371,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
],
}))
@ -362,6 +381,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.REJECTED,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.REJECTED,
@ -371,6 +391,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
signingStatus: SigningStatus.REJECTED,
},
},
folderId: folderId,
},
],
}))
@ -410,6 +431,7 @@ const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[],
folderId?: string,
) => {
const teamEmail = team.teamEmail?.email ?? null;
@ -420,6 +442,7 @@ const findTeamDocumentsFilter = (
OR: [
{
teamId: team.id,
folderId: folderId,
OR: visibilityFilters,
},
],
@ -437,6 +460,7 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
});
// Filter to display all documents that have been sent by the team email.
@ -445,6 +469,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
});
}
@ -470,6 +495,7 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
};
})
.with(ExtendedDocumentStatus.DRAFT, () => {
@ -479,6 +505,7 @@ const findTeamDocumentsFilter = (
teamId: team.id,
status: ExtendedDocumentStatus.DRAFT,
OR: visibilityFilters,
folderId: folderId,
},
],
};
@ -490,6 +517,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
});
}
@ -502,6 +530,7 @@ const findTeamDocumentsFilter = (
teamId: team.id,
status: ExtendedDocumentStatus.PENDING,
OR: visibilityFilters,
folderId: folderId,
},
],
};
@ -521,12 +550,14 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
},
{
user: {
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
},
],
});

View File

@ -0,0 +1,38 @@
import { prisma } from '@documenso/prisma';
export type GetDocumentByAccessTokenOptions = {
token: string;
};
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
if (!token) {
throw new Error('Missing token');
}
const result = await prisma.document.findFirstOrThrow({
where: {
qrToken: token,
},
select: {
id: true,
title: true,
completedAt: true,
documentData: {
select: {
id: true,
type: true,
data: true,
initialData: true,
},
},
documentMeta: {
select: {
password: true,
},
},
recipients: true,
},
});
return result;
};

View File

@ -12,9 +12,15 @@ export type GetDocumentByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
folderId?: string;
};
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
export const getDocumentById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId,
userId,
@ -22,7 +28,10 @@ export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumen
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: true,
documentMeta: true,

View File

@ -7,12 +7,14 @@ export type GetDocumentWithDetailsByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
folderId?: string;
};
export const getDocumentWithDetailsById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentWithDetailsByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId,
@ -21,13 +23,17 @@ export const getDocumentWithDetailsById = async ({
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: true,
documentMeta: true,
recipients: true,
fields: true,
attachments: true,
folder: true,
},
});

View File

@ -15,9 +15,16 @@ export type GetStatsInput = {
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
search?: string;
folderId?: string;
};
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => {
export const getStats = async ({
user,
period,
search = '',
folderId,
...options
}: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@ -37,8 +44,9 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
currentUserEmail: user.email,
userId: user.id,
search,
folderId,
})
: getCounts({ user, createdAt, search }));
: getCounts({ user, createdAt, search, folderId }));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
@ -84,9 +92,10 @@ type GetCountsOption = {
user: User;
createdAt: Prisma.DocumentWhereInput['createdAt'];
search?: string;
folderId?: string | null;
};
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
@ -95,6 +104,8 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
return Promise.all([
// Owner counts.
prisma.document.groupBy({
@ -107,7 +118,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Not signed counts.
@ -126,7 +137,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
createdAt,
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Has signed counts.
@ -164,7 +175,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
],
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
]);
@ -179,10 +190,11 @@ type GetTeamCountsOption = {
createdAt: Prisma.DocumentWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
folderId?: string | null;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
const { createdAt, teamId, teamEmail } = options;
const { createdAt, teamId, teamEmail, folderId } = options;
const senderIds = options.senderIds ?? [];
@ -206,6 +218,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -278,6 +291,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
where: {
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -298,6 +312,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
where: {
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,

View File

@ -0,0 +1,86 @@
import { TeamMemberRole } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { determineDocumentVisibility } from '../../utils/document-visibility';
export interface CreateFolderOptions {
userId: number;
teamId?: number;
name: string;
parentId?: string | null;
type?: TFolderType;
}
export const createFolder = async ({
userId,
teamId,
name,
parentId,
type = FolderType.DOCUMENT,
}: CreateFolderOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
teamMembers: {
select: {
teamId: true,
},
},
},
});
if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
let userTeamRole: TeamMemberRole | undefined;
if (teamId) {
const teamWithUserRole = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
});
team = teamWithUserRole;
userTeamRole = teamWithUserRole.members[0]?.role;
}
return await prisma.folder.create({
data: {
name,
userId,
teamId,
parentId,
type,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
},
});
};

View File

@ -0,0 +1,85 @@
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export interface DeleteFolderOptions {
userId: number;
teamId?: number;
folderId: string;
}
export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOptions) => {
let teamMemberRole: TeamMemberRole | null = null;
if (teamId) {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
teamMemberRole = team.members[0]?.role ?? null;
}
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
include: {
documents: true,
subfolders: true,
templates: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (teamId && teamMemberRole) {
const hasPermission = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => folder.visibility !== DocumentVisibility.ADMIN)
.with(TeamMemberRole.MEMBER, () => folder.visibility === DocumentVisibility.EVERYONE)
.otherwise(() => false);
if (!hasPermission) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to delete this folder',
});
}
}
return await prisma.folder.delete({
where: {
id: folderId,
},
});
};

View File

@ -0,0 +1,152 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface FindFoldersOptions {
userId: number;
teamId?: number;
parentId?: string | null;
type?: TFolderType;
}
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
let team = null;
let teamMemberRole = null;
if (teamId !== undefined) {
try {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
throw error;
}
}
const visibilityFilters = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
AND: [
{ parentId },
teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null },
],
};
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.document.count({
where: {
folderId: folder.id,
},
}),
prisma.template.count({
where: {
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
};

View File

@ -0,0 +1,112 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface GetFolderBreadcrumbsOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const getFolderBreadcrumbs = async ({
userId,
teamId,
folderId,
type,
}: GetFolderBreadcrumbsOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
return [];
}
}
const visibilityFilters = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = (folderId: string) => ({
id: folderId,
...(type ? { type } : {}),
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
});
const breadcrumbs = [];
let currentFolderId = folderId;
const currentFolder = await prisma.folder.findFirst({
where: whereClause(currentFolderId),
});
if (!currentFolder) {
return [];
}
breadcrumbs.push(currentFolder);
while (currentFolder?.parentId) {
const parentFolder = await prisma.folder.findFirst({
where: whereClause(currentFolder.parentId),
});
if (!parentFolder) {
break;
}
breadcrumbs.unshift(parentFolder);
currentFolderId = parentFolder.id;
currentFolder.parentId = parentFolder.parentId;
}
return breadcrumbs;
};

View File

@ -0,0 +1,92 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface GetFolderByIdOptions {
userId: number;
teamId?: number;
folderId?: string;
type?: TFolderType;
}
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
}
const visibilityFilters = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
id: folderId,
...(type ? { type } : {}),
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const folder = await prisma.folder.findFirst({
where: whereClause,
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return folder;
};

View File

@ -0,0 +1,130 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { FolderType } from '@documenso/lib/types/folder-type';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export interface MoveDocumentToFolderOptions {
userId: number;
teamId?: number;
documentId: number;
folderId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveDocumentToFolder = async ({
userId,
teamId,
documentId,
folderId,
}: MoveDocumentToFolderOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
}
const visibilityFilters = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const documentWhereClause = {
id: documentId,
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const document = await prisma.document.findFirst({
where: documentWhereClause,
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (folderId) {
const folderWhereClause = {
id: folderId,
type: FolderType.DOCUMENT,
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const folder = await prisma.folder.findFirst({
where: folderWhereClause,
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
return await prisma.document.update({
where: {
id: documentId,
},
data: {
folderId,
},
});
};

View File

@ -0,0 +1,85 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export interface MoveFolderOptions {
userId: number;
teamId?: number;
folderId?: string;
parentId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (parentId) {
const parentFolder = await tx.folder.findFirst({
where: {
id: parentId,
userId,
teamId,
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await tx.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await tx.folder.update({
where: {
id: folderId,
},
data: {
parentId,
},
});
});
};

View File

@ -0,0 +1,59 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { prisma } from '@documenso/prisma';
export interface MoveTemplateToFolderOptions {
userId: number;
teamId?: number;
templateId: number;
folderId?: string | null;
}
export const moveTemplateToFolder = async ({
userId,
teamId,
templateId,
folderId,
}: MoveTemplateToFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const template = await tx.template.findFirst({
where: {
id: templateId,
userId,
teamId,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
if (folderId !== null) {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type: FolderType.TEMPLATE,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
return await tx.template.update({
where: {
id: templateId,
},
data: {
folderId,
},
});
});
};

View File

@ -0,0 +1,37 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
export interface PinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: true,
},
});
};

View File

@ -0,0 +1,37 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
export interface UnpinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: false,
},
});
};

View File

@ -0,0 +1,53 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
export interface UpdateFolderOptions {
userId: number;
teamId?: number;
folderId: string;
name: string;
visibility: DocumentVisibility;
type?: TFolderType;
}
export const updateFolder = async ({
userId,
teamId,
folderId,
name,
visibility,
type,
}: UpdateFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
const effectiveVisibility =
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
name,
visibility: effectiveVisibility,
},
});
};

View File

@ -252,7 +252,10 @@ export const setDocumentRecipients = async ({
});
}
return upsertedRecipient;
return {
...upsertedRecipient,
clientId: recipient.clientId,
};
}),
);
});
@ -332,7 +335,7 @@ export const setDocumentRecipients = async ({
}
// Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
@ -353,6 +356,7 @@ export const setDocumentRecipients = async ({
*/
type RecipientData = {
id?: number | null;
clientId?: string | null;
email: string;
name: string;
role: RecipientRole;
@ -361,6 +365,10 @@ type RecipientData = {
actionAuth?: TRecipientActionAuthTypes | null;
};
type RecipientDataWithClientId = Recipient & {
clientId?: string | null;
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);

View File

@ -19,7 +19,7 @@ import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
@ -276,6 +276,7 @@ export const createDocumentFromDirectTemplate = async ({
// Create the document and non direct template recipients.
const document = await tx.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: template.id,
userId: template.userId,

View File

@ -1,6 +1,6 @@
import { DocumentSource, type RecipientRole } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
export type CreateDocumentFromTemplateLegacyOptions = {
@ -70,6 +70,7 @@ export const createDocumentFromTemplateLegacy = async ({
const document = await prisma.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
templateId: template.id,
userId,

View File

@ -11,7 +11,7 @@ import {
} from '@prisma/client';
import { match } from 'ts-pattern';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { SupportedLanguageCodes } from '../../constants/i18n';
@ -373,6 +373,7 @@ export const createDocumentFromTemplate = async ({
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId,
templateId: template.id,

View File

@ -20,6 +20,7 @@ export const createTemplate = async ({
userId,
teamId,
templateDocumentDataId,
folderId,
}: CreateTemplateOptions) => {
let team = null;
@ -43,12 +44,46 @@ export const createTemplate = async ({
}
}
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
if (teamId && !team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return await prisma.template.create({
data: {
title,
userId,
templateDocumentDataId,
teamId,
folderId: folderId,
templateMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,

View File

@ -12,6 +12,7 @@ export type FindTemplatesOptions = {
type?: Template['type'];
page?: number;
perPage?: number;
folderId?: string;
};
export const findTemplates = async ({
@ -20,6 +21,7 @@ export const findTemplates = async ({
type,
page = 1,
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.TemplateWhereInput[] = [];
@ -67,6 +69,12 @@ export const findTemplates = async ({
);
}
if (folderId) {
whereFilter.push({ folderId });
} else {
whereFilter.push({ folderId: null });
}
const [data, count] = await Promise.all([
prisma.template.findMany({
where: {

View File

@ -6,9 +6,15 @@ export type GetTemplateByIdOptions = {
id: number;
userId: number;
teamId?: number;
folderId?: string | null;
};
export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOptions) => {
export const getTemplateById = async ({
id,
userId,
teamId,
folderId = null,
}: GetTemplateByIdOptions) => {
const template = await prisma.template.findFirst({
where: {
id,
@ -27,6 +33,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
userId,
teamId: null,
}),
...(folderId ? { folderId } : {}),
},
include: {
directLink: true,
@ -42,6 +49,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
email: true,
},
},
folder: true,
},
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import { AttachmentSchema } from '@documenso/prisma/generated/zod/modelSchema/At
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { DocumentSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
@ -32,6 +33,7 @@ export const ZDocumentSchema = DocumentSchema.pick({
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
}).extend({
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
documentData: DocumentDataSchema.pick({
@ -58,6 +60,18 @@ export const ZDocumentSchema = DocumentSchema.pick({
language: true,
emailSettings: true,
}).nullable(),
folder: FolderSchema.pick({
id: true,
name: true,
type: true,
visibility: true,
userId: true,
teamId: true,
pinned: true,
parentId: true,
createdAt: true,
updatedAt: true,
}).nullable(),
recipients: ZRecipientLiteSchema.array(),
fields: ZFieldSchema.array(),
attachments: AttachmentSchema.pick({
@ -92,9 +106,12 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
useLegacyFieldInsertion: true,
});
export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
/**
* A version of the document response schema when returning multiple documents at once from a single API endpoint.
*/
@ -115,6 +132,7 @@ export const ZDocumentManySchema = DocumentSchema.pick({
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
useLegacyFieldInsertion: true,
}).extend({
user: UserSchema.pick({
@ -128,3 +146,5 @@ export const ZDocumentManySchema = DocumentSchema.pick({
url: true,
}).nullable(),
});
export type TDocumentMany = z.infer<typeof ZDocumentManySchema>;

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const FolderType = {
DOCUMENT: 'DOCUMENT',
TEMPLATE: 'TEMPLATE',
} as const;
export const ZFolderTypeSchema = z.enum([FolderType.DOCUMENT, FolderType.TEMPLATE]);
export type TFolderType = z.infer<typeof ZFolderTypeSchema>;

View File

@ -2,6 +2,7 @@ import type { z } from 'zod';
import { AttachmentSchema } from '@documenso/prisma/generated/zod/modelSchema/AttachmentSchema';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
import { TemplateMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateMetaSchema';
@ -30,6 +31,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
updatedAt: true,
publicTitle: true,
publicDescription: true,
folderId: true,
}).extend({
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
templateDocumentData: DocumentDataSchema.pick({
@ -71,6 +73,18 @@ export const ZTemplateSchema = TemplateSchema.pick({
})
.array()
.optional(),
folder: FolderSchema.pick({
id: true,
name: true,
type: true,
visibility: true,
userId: true,
teamId: true,
pinned: true,
parentId: true,
createdAt: true,
updatedAt: true,
}).nullable(),
});
export type TTemplate = z.infer<typeof ZTemplateSchema>;
@ -92,6 +106,7 @@ export const ZTemplateLiteSchema = TemplateSchema.pick({
updatedAt: true,
publicTitle: true,
publicDescription: true,
folderId: true,
useLegacyFieldInsertion: true,
});
@ -112,6 +127,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
updatedAt: true,
publicTitle: true,
publicDescription: true,
folderId: true,
useLegacyFieldInsertion: true,
}).extend({
team: TeamSchema.pick({

View File

@ -3,3 +3,9 @@ import { customAlphabet } from 'nanoid';
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
export { nanoid } from 'nanoid';
export const fancyId = customAlphabet('abcdefhiklmnorstuvwxyz', 16);
export const prefixedId = (prefix: string, length = 16) => {
return `${prefix}_${fancyId(length)}`;
};

View File

@ -0,0 +1,5 @@
export function formatFolderCount(count: number, singular: string, plural?: string): string {
const itemLabel = count === 1 ? singular : plural || `${singular}s`;
return `${count} ${itemLabel}`;
}