This commit is contained in:
David Nguyen
2025-08-22 22:30:02 +10:00
parent e7e2aa9bd8
commit e1464ac2d3
17 changed files with 787 additions and 258 deletions

View File

@ -1,39 +1,32 @@
import type { Prisma } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentVisibility } from '../../types/document-visibility';
import { getTeamById } from '../team/get-team';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type GetDocumentByIdOptions = {
documentId: number;
userId: number;
teamId: number;
folderId?: string;
};
export const getDocumentById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentByIdOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
const { documentWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
validatedUserId: userId,
unvalidatedTeamId: teamId,
});
const document = await prisma.document.findFirst({
where: {
...documentWhereInput,
folderId,
},
const envelope = await prisma.envelope.findFirst({
where: documentWhereInput,
include: {
documentData: true,
documents: {
include: {
documentData: true,
},
},
documentMeta: true,
user: {
select: {
@ -56,7 +49,7 @@ export const getDocumentById = async ({
},
});
if (!document) {
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document could not be found',
});
@ -64,93 +57,3 @@ export const getDocumentById = async ({
return document;
};
export type GetDocumentWhereInputOptions = {
documentId: number;
userId: number;
teamId: number;
};
/**
* 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,
}: GetDocumentWhereInputOptions) => {
const team = await getTeamById({ teamId, userId });
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const teamVisibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
])
.with(TeamMemberRole.MANAGER, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
])
.otherwise(() => [DocumentVisibility.EVERYONE]);
const documentOrInput: Prisma.DocumentWhereInput[] = [
// Allow access if they own the document.
{
userId,
},
// Or, if they belong to the team that the document is associated with.
{
visibility: {
in: teamVisibilityFilters,
},
teamId: team.id,
},
// Or, if they are a recipient of the document.
{
status: {
not: DocumentStatus.DRAFT,
},
recipients: {
some: {
email: user.email,
},
},
},
];
// Allow access to documents sent to or from the team email.
if (team.teamEmail) {
documentOrInput.push(
{
recipients: {
some: {
email: team.teamEmail.email,
},
},
},
{
user: {
email: team.teamEmail.email,
},
},
);
}
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
id: documentId,
OR: documentOrInput,
};
return {
documentWhereInput,
team,
};
};

View File

@ -0,0 +1,157 @@
import type { Prisma } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentVisibility } from '../../types/document-visibility';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildEnvelopeIdQuery } from '../../utils/envelope';
import { getTeamById } from '../team/get-team';
export type GetEnvelopeByIdOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
export const getEnvelopeById = async ({ id, userId, teamId }: GetEnvelopeByIdOptions) => {
const { documentWhereInput } = await getEnvelopeWhereInput({
id,
validatedUserId: userId,
unvalidatedTeamId: teamId,
});
const document = await prisma.envelope.findFirst({
where: documentWhereInput,
include: {
documents: {
include: {
documentData: true,
},
},
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
select: {
email: true,
},
},
team: {
select: {
id: true,
url: true,
},
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document could not be found',
});
}
return document;
};
export type GetEnvelopeWhereInputOptions = {
id: EnvelopeIdOptions;
/**
* The user ID who has been authenticated.
*/
validatedUserId: number;
/**
* The unknown teamId from the request.
*/
unvalidatedTeamId: number;
};
/**
* Generate the where input for a given Prisma envelope query.
*
* This will return a query that allows a user to get a document if they have valid access to it.
*/
export const getEnvelopeWhereInput = async ({
id,
validatedUserId,
unvalidatedTeamId,
}: GetEnvelopeWhereInputOptions) => {
const team = await getTeamById({ teamId: unvalidatedTeamId, userId: validatedUserId });
const teamVisibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
])
.with(TeamMemberRole.MANAGER, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
])
.otherwise(() => [DocumentVisibility.EVERYONE]);
const documentOrInput: Prisma.EnvelopeWhereInput[] = [
// Allow access if they own the document.
{
userId: validatedUserId,
},
// Or, if they belong to the team that the document is associated with.
{
visibility: {
in: teamVisibilityFilters,
},
teamId: team.id,
},
// Or, if they are a recipient of the document.
// ????????????? should recipients be able to do X?
// {
// status: {
// not: DocumentStatus.DRAFT,
// },
// recipients: {
// some: {
// email: user.email,
// },
// },
// },
];
// Allow access to documents sent to or from the team email.
if (team.teamEmail) {
documentOrInput.push(
{
recipients: {
some: {
email: team.teamEmail.email,
},
},
},
{
user: {
email: team.teamEmail.email,
},
},
);
}
const documentWhereInput: Prisma.EnvelopeWhereUniqueInput = {
...buildEnvelopeIdQuery(id),
OR: documentOrInput,
};
return {
documentWhereInput,
team,
};
};

View File

@ -1,4 +1,4 @@
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@ -25,9 +25,10 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
const serviceAccount = await deletedAccountServiceAccount();
// TODO: Send out cancellations for all pending docs
await prisma.document.updateMany({
await prisma.envelope.updateMany({
where: {
userId: user.id,
type: EnvelopeType.DOCUMENT,
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},

View File

@ -1,4 +1,4 @@
import { Prisma } from '@prisma/client';
import { EnvelopeType, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@ -34,12 +34,20 @@ export const findUsers = async ({
const [users, count] = await Promise.all([
prisma.user.findMany({
include: {
documents: {
select: {
_count: {
select: {
id: true,
envelopes: {
where: {
type: EnvelopeType.DOCUMENT,
},
},
},
},
id: true,
name: true,
email: true,
roles: true,
},
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
@ -51,7 +59,10 @@ export const findUsers = async ({
]);
return {
users,
users: users.map((user) => ({
...user,
documentCount: user._count.envelopes,
})),
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -1,11 +1,11 @@
import type { z } from 'zod';
import { z } from 'zod';
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';
import { LegacyDocumentSchema } from '@documenso/prisma/types/document-legacy-schema';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
@ -15,7 +15,7 @@ import { ZRecipientLiteSchema } from './recipient';
*
* Mainly used for returning a single document from the API.
*/
export const ZDocumentSchema = DocumentSchema.pick({
export const ZDocumentSchema = LegacyDocumentSchema.pick({
visibility: true,
status: true,
source: true,
@ -31,9 +31,12 @@ export const ZDocumentSchema = DocumentSchema.pick({
completedAt: true,
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
}).extend({
// Which "Template" the document was created from. Legacy field for backwards compatibility.
// The actual field is now called `createdFromDocumentId`.
templateId: z.number().optional(),
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
documentData: DocumentDataSchema.pick({
type: true,
@ -82,7 +85,7 @@ export type TDocument = z.infer<typeof ZDocumentSchema>;
/**
* A lite version of the document response schema without relations.
*/
export const ZDocumentLiteSchema = DocumentSchema.pick({
export const ZDocumentLiteSchema = LegacyDocumentSchema.pick({
visibility: true,
status: true,
source: true,
@ -98,9 +101,12 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
completedAt: true,
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
useLegacyFieldInsertion: true,
}).extend({
// Which "Template" the document was created from. Legacy field for backwards compatibility.
// The actual field is now called `createdFromDocumentId`.
templateId: z.number().optional(),
});
export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
@ -108,7 +114,7 @@ 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.
*/
export const ZDocumentManySchema = DocumentSchema.pick({
export const ZDocumentManySchema = LegacyDocumentSchema.pick({
visibility: true,
status: true,
source: true,
@ -124,10 +130,13 @@ export const ZDocumentManySchema = DocumentSchema.pick({
completedAt: true,
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
useLegacyFieldInsertion: true,
}).extend({
// Which "Template" the document was created from. Legacy field for backwards compatibility.
// The actual field is now called `createdFromDocumentId`.
templateId: z.number().optional(),
user: UserSchema.pick({
id: true,
name: true,

View File

@ -18,8 +18,6 @@ export const ZFieldSchema = FieldSchema.pick({
type: true,
id: true,
secondaryId: true,
documentId: true,
templateId: true,
recipientId: true,
page: true,
positionX: true,
@ -29,6 +27,10 @@ export const ZFieldSchema = FieldSchema.pick({
customText: true,
inserted: true,
fieldMeta: true,
}).extend({
// Todo: Decide whether to make these two IDs backwards compatible.
documentId: z.number().optional(),
templateId: z.number().optional(),
});
export const ZFieldPageNumberSchema = z

View File

@ -1,3 +1,5 @@
import { z } from 'zod';
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
@ -15,8 +17,6 @@ export const ZRecipientSchema = RecipientSchema.pick({
signingStatus: true,
sendStatus: true,
id: true,
documentId: true,
templateId: true,
email: true,
name: true,
token: true,
@ -28,6 +28,10 @@ export const ZRecipientSchema = RecipientSchema.pick({
rejectionReason: true,
}).extend({
fields: ZFieldSchema.array(),
// Todo: Decide whether to make these two IDs backwards compatible.
documentId: z.number().optional(),
templateId: z.number().optional(),
});
/**
@ -39,8 +43,6 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
signingStatus: true,
sendStatus: true,
id: true,
documentId: true,
templateId: true,
email: true,
name: true,
token: true,
@ -50,6 +52,10 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
authOptions: true,
signingOrder: true,
rejectionReason: true,
}).extend({
// Todo: Decide whether to make these two IDs backwards compatible.
documentId: z.number().optional(),
templateId: z.number().optional(),
});
/**
@ -61,8 +67,6 @@ export const ZRecipientManySchema = RecipientSchema.pick({
signingStatus: true,
sendStatus: true,
id: true,
documentId: true,
templateId: true,
email: true,
name: true,
token: true,
@ -83,4 +87,8 @@ export const ZRecipientManySchema = RecipientSchema.pick({
id: true,
url: true,
}).nullable(),
// Todo: Decide whether to make these two IDs backwards compatible.
documentId: z.number().optional(),
templateId: z.number().optional(),
});

View File

@ -1,12 +1,12 @@
import type { z } from 'zod';
import { z } from 'zod';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
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 { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { TemplateSchema } from '@documenso/prisma/types/template-legacy-schema';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
@ -51,13 +51,17 @@ export const ZTemplateSchema = TemplateSchema.pick({
drawSignatureEnabled: true,
allowDictateNextSigner: true,
distributionMethod: true,
templateId: true,
redirectUrl: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}).nullable(),
})
.extend({
// Legacy field for backwards compatibility. Needs to refer to the Envelope `secondaryTemplateId`.
templateId: z.number(),
})
.nullable(),
directLink: TemplateDirectLinkSchema.nullable(),
user: UserSchema.pick({
id: true,

View File

@ -1,10 +1,10 @@
import type { Document, DocumentMeta, OrganisationGlobalSettings } from '@prisma/client';
import type { DocumentMeta, Envelope, OrganisationGlobalSettings } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
@ -53,5 +53,5 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
} satisfies Omit<DocumentMeta, 'id' | 'documentId' | 'templateId'>;
} satisfies Omit<DocumentMeta, 'id' | 'envelopeId'>;
};

View File

@ -0,0 +1,92 @@
import { EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../errors/app-error';
const envelopeDocumentPrefixId = 'document';
const envelopeTemplatePrefixId = 'template';
const envelopePrefixId = 'envelope';
const ZDocumentIdSchema = z.string().regex(/^document_\d+$/);
const ZTemplateIdSchema = z.string().regex(/^template_\d+$/);
const ZEnvelopeIdSchema = z.string().regex(/^envelope_\d+$/);
export type EnvelopeIdOptions =
| {
type: 'envelopeId';
id: string;
}
| {
type: 'documentId';
id: string | number;
}
| {
type: 'templateId';
id: string | number;
};
/**
* Parses an unknown document or template ID.
*
* @param id
* @param type
* @returns
*/
export const buildEnvelopeIdQuery = (options: EnvelopeIdOptions) => {
return match(options)
.with({ type: 'envelopeId' }, (value) => {
const parsed = ZEnvelopeIdSchema.safeParse(value.id);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid envelope ID',
});
}
return {
id: value.id,
};
})
.with({ type: 'documentId' }, (value) => ({
type: EnvelopeType.DOCUMENT,
secondaryId: parseDocumentIdToEnvelopeSecondaryId(value.id),
}))
.with({ type: 'templateId' }, (value) => ({
type: EnvelopeType.TEMPLATE,
secondaryId: parseTemplateIdToEnvelopeSecondaryId(value.id),
}))
.exhaustive();
};
export const parseDocumentIdToEnvelopeSecondaryId = (documentId: string | number) => {
if (typeof documentId === 'number') {
return `${envelopeDocumentPrefixId}_${documentId}`;
}
const parsed = ZDocumentIdSchema.safeParse(documentId);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid document ID',
});
}
return parsed.data;
};
export const parseTemplateIdToEnvelopeSecondaryId = (templateId: string | number) => {
if (typeof templateId === 'number') {
return `${envelopeTemplatePrefixId}_${templateId}`;
}
const parsed = ZTemplateIdSchema.safeParse(templateId);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid template ID',
});
}
return parsed.data;
};

View File

@ -1,4 +1,4 @@
import type { Recipient } from '@prisma/client';
import { type Recipient } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';