mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
Merge branch 'main' into feat/public-completed-document-access
This commit is contained in:
@@ -8,7 +8,10 @@ import {
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import {
|
||||
DOCUMENT_AUDIT_LOG_TYPE,
|
||||
RECIPIENT_DIFF_TYPE,
|
||||
@@ -109,7 +112,9 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw new Error(
|
||||
@@ -118,46 +123,6 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
let recipientName = recipient.name;
|
||||
let recipientEmail = recipient.email;
|
||||
|
||||
// Only trim the name if it's been derived.
|
||||
if (!recipientName) {
|
||||
recipientName = (
|
||||
recipientOverride?.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
''
|
||||
).trim();
|
||||
}
|
||||
|
||||
// Only trim the email if it's been derived.
|
||||
if (!recipient.email) {
|
||||
recipientEmail = (
|
||||
recipientOverride?.email ||
|
||||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
||||
''
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
if (!recipientEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Recipient email is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Check ACCESS AUTH 2FA validation during document completion
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
@@ -216,6 +181,112 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
}
|
||||
|
||||
let fields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
// This should be scoped to the current recipient.
|
||||
const uninsertedDateFields = fields.filter(
|
||||
(field) => field.type === FieldType.DATE && !field.inserted,
|
||||
);
|
||||
|
||||
let recipientName = recipient.name;
|
||||
let recipientEmail = recipient.email;
|
||||
|
||||
// Only trim the name if it's been derived.
|
||||
if (!recipientName) {
|
||||
recipientName = (
|
||||
recipientOverride?.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
''
|
||||
).trim();
|
||||
}
|
||||
|
||||
// Only trim the email if it's been derived.
|
||||
if (!recipient.email) {
|
||||
recipientEmail = (
|
||||
recipientOverride?.email ||
|
||||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
||||
''
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
if (!recipientEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Recipient email is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-insert all un-inserted date fields for V2 envelopes at completion time.
|
||||
if (envelope.internalVersion === 2 && uninsertedDateFields.length > 0) {
|
||||
const formattedDate = DateTime.now()
|
||||
.setZone(envelope.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
||||
.toFormat(envelope.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
|
||||
const newDateFieldValues = {
|
||||
customText: formattedDate,
|
||||
inserted: true,
|
||||
};
|
||||
|
||||
await prisma.field.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: uninsertedDateFields.map((field) => field.id),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...newDateFieldValues,
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log entries for each auto-inserted date field.
|
||||
await prisma.documentAuditLog.createMany({
|
||||
data: uninsertedDateFields.map((field) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
email: recipientEmail,
|
||||
name: recipientName,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipientEmail,
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipientName,
|
||||
recipientRole: recipient.role,
|
||||
fieldId: field.secondaryId,
|
||||
field: {
|
||||
type: FieldType.DATE,
|
||||
data: formattedDate,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Update the local fields array so the subsequent validation check passes.
|
||||
fields = fields.map((field) => {
|
||||
if (field.type === FieldType.DATE && !field.inserted) {
|
||||
return {
|
||||
...field,
|
||||
...newDateFieldValues,
|
||||
};
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
}
|
||||
|
||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
@@ -286,6 +357,18 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: { documentMeta: true, recipients: true },
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.recipient.signed.email',
|
||||
payload: {
|
||||
@@ -359,6 +442,7 @@ export const completeDocumentWithToken = async ({
|
||||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
@@ -367,16 +451,16 @@ export const completeDocumentWithToken = async ({
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,13 @@ export const deleteDocument = async ({
|
||||
user,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
// Continue to hide the document from the user if they are a recipient.
|
||||
@@ -112,13 +119,6 @@ export const deleteDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return envelope;
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,7 @@ export const getDocumentAndSenderByToken = async ({
|
||||
title: true,
|
||||
order: true,
|
||||
envelopeId: true,
|
||||
documentDataId: true,
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,368 +1,307 @@
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||
import type { DB } from '@documenso/prisma/generated/types';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
import { STATS_COUNT_CAP } from '../../constants/document';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
// Kysely query builder type for Envelope queries.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
|
||||
|
||||
// Expression builder type scoped to Envelope table context.
|
||||
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
|
||||
type RecipientExpressionBuilder = ExpressionBuilder<DB, 'Recipient'>;
|
||||
|
||||
/**
|
||||
* Reusable EXISTS subquery: checks that a Recipient row exists for the given
|
||||
* envelope with the given email, plus optional extra conditions.
|
||||
*/
|
||||
const recipientExists = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
email: string,
|
||||
extra?: (qb: RecipientExpressionBuilder) => Expression<SqlBool>,
|
||||
) => {
|
||||
let sub = eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.email', '=', email);
|
||||
|
||||
if (extra) {
|
||||
sub = sub.where(extra);
|
||||
}
|
||||
|
||||
return eb.exists(sub.select(sql.lit(1).as('one')));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email.
|
||||
*/
|
||||
const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('User')
|
||||
.whereRef('User.id', '=', 'Envelope.userId')
|
||||
.where('User.email', '=', email)
|
||||
.select(sql.lit(1).as('one')),
|
||||
);
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
folderId?: string;
|
||||
senderIds?: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a capped count from a query builder: wraps it as
|
||||
* `SELECT COUNT(*) FROM (SELECT id FROM ... LIMIT cap+1) sub`
|
||||
* and clamps the result to STATS_COUNT_CAP.
|
||||
*/
|
||||
const cappedCount = async (qb: EnvelopeQueryBuilder): Promise<number> => {
|
||||
const result = await kyselyPrisma.$kysely
|
||||
.selectFrom(
|
||||
qb
|
||||
.clearSelect()
|
||||
.select('Envelope.id')
|
||||
.limit(STATS_COUNT_CAP + 1)
|
||||
.as('capped'),
|
||||
)
|
||||
.select(({ fn }) => fn.count<number>('id').as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP);
|
||||
};
|
||||
|
||||
export const getStats = async ({
|
||||
user,
|
||||
userId,
|
||||
teamId,
|
||||
period,
|
||||
search = '',
|
||||
folderId,
|
||||
...options
|
||||
senderIds,
|
||||
}: GetStatsInput) => {
|
||||
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
|
||||
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
|
||||
|
||||
createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
const searchQuery = search.trim();
|
||||
const hasSearch = searchQuery.length > 0;
|
||||
const searchPattern = `%${searchQuery}%`;
|
||||
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
|
||||
? getTeamCounts({
|
||||
...options.team,
|
||||
createdAt,
|
||||
currentUserEmail: user.email,
|
||||
userId: user.id,
|
||||
search,
|
||||
folderId,
|
||||
})
|
||||
: getCounts({ user, createdAt, search, folderId }));
|
||||
// ─── Base query builder ──────────────────────────────────────────────
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
const buildBaseQuery = (): EnvelopeQueryBuilder => {
|
||||
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope')
|
||||
.select('Envelope.id');
|
||||
|
||||
// Type = DOCUMENT
|
||||
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
|
||||
|
||||
// Folder filter
|
||||
qb =
|
||||
folderId !== undefined
|
||||
? qb.where('Envelope.folderId', '=', folderId)
|
||||
: qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
qb = qb.where('Envelope.createdAt', '>=', startOfPeriod.toJSDate());
|
||||
}
|
||||
|
||||
// Sender filter
|
||||
if (senderIds && senderIds.length > 0) {
|
||||
qb = qb.where('Envelope.userId', 'in', senderIds);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (hasSearch) {
|
||||
qb = qb.where(({ or, eb }) =>
|
||||
or([
|
||||
eb('Envelope.title', 'ilike', searchPattern),
|
||||
eb('Envelope.externalId', 'ilike', searchPattern),
|
||||
eb(
|
||||
'Envelope.id',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.select('Recipient.envelopeId')
|
||||
.where(({ or: innerOr, eb: innerEb }) =>
|
||||
innerOr([
|
||||
innerEb('Recipient.email', 'ilike', searchPattern),
|
||||
innerEb('Recipient.name', 'ilike', searchPattern),
|
||||
]),
|
||||
)
|
||||
.distinct()
|
||||
.limit(1000),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return qb;
|
||||
};
|
||||
|
||||
ownerCounts.forEach((stat) => {
|
||||
stats[stat.status] = stat._count._all;
|
||||
});
|
||||
// ─── Shared filter helpers ───────────────────────────────────────────
|
||||
|
||||
notSignedCounts.forEach((stat) => {
|
||||
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
|
||||
});
|
||||
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email),
|
||||
]);
|
||||
|
||||
hasSignedCounts.forEach((stat) => {
|
||||
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
|
||||
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
|
||||
const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => {
|
||||
const branches = [
|
||||
eb.and([eb('Envelope.teamId', '=', team.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
];
|
||||
|
||||
if (teamEmail) {
|
||||
branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
|
||||
branches.push(
|
||||
recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
||||
);
|
||||
}
|
||||
|
||||
if (stat.status === ExtendedDocumentStatus.PENDING) {
|
||||
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
|
||||
}
|
||||
return eb.or(branches);
|
||||
};
|
||||
|
||||
if (stat.status === ExtendedDocumentStatus.REJECTED) {
|
||||
stats[ExtendedDocumentStatus.REJECTED] += stat._count._all;
|
||||
}
|
||||
});
|
||||
// ─── Per-status query builders ───────────────────────────────────────
|
||||
|
||||
Object.keys(stats).forEach((key) => {
|
||||
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
|
||||
stats[ExtendedDocumentStatus.ALL] += stats[key];
|
||||
}
|
||||
});
|
||||
// DRAFT: team-owned drafts visible to the user
|
||||
const draftQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// PENDING: team-owned pending + team-email signed-pending docs
|
||||
const pendingQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// COMPLETED: team-owned completed + team-email received completed
|
||||
const completedQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// REJECTED: team-owned rejected + team-email rejected docs
|
||||
const rejectedQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
|
||||
// Returns 0 if the team has no team email.
|
||||
const inboxQuery = teamEmail
|
||||
? buildBaseQuery()
|
||||
.where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
visibilityFilter(eb),
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
: null;
|
||||
|
||||
// ─── Execute all counts in parallel ──────────────────────────────────
|
||||
|
||||
const [draft, pending, completed, rejected, inbox] = await Promise.all([
|
||||
cappedCount(draftQuery),
|
||||
cappedCount(pendingQuery),
|
||||
cappedCount(completedQuery),
|
||||
cappedCount(rejectedQuery),
|
||||
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: draft,
|
||||
[ExtendedDocumentStatus.PENDING]: pending,
|
||||
[ExtendedDocumentStatus.COMPLETED]: completed,
|
||||
[ExtendedDocumentStatus.REJECTED]: rejected,
|
||||
[ExtendedDocumentStatus.INBOX]: inbox,
|
||||
[ExtendedDocumentStatus.ALL]: all,
|
||||
};
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
type GetCountsOption = {
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
return Promise.all([
|
||||
// Owner counts.
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
createdAt,
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
// Not signed counts.
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
// Has signed counts.
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
createdAt,
|
||||
user: {
|
||||
email: {
|
||||
not: user.email,
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
type GetTeamCountsOption = {
|
||||
teamId: number;
|
||||
teamEmail?: string;
|
||||
senderIds?: number[];
|
||||
currentUserEmail: string;
|
||||
userId: number;
|
||||
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
currentTeamMemberRole?: TeamMemberRole;
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
const { createdAt, teamId, teamEmail, folderId } = options;
|
||||
|
||||
const senderIds = options.senderIds ?? [];
|
||||
|
||||
const userIdWhereClause: Prisma.EnvelopeWhereInput['userId'] =
|
||||
senderIds.length > 0
|
||||
? {
|
||||
in: senderIds,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: options.search, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
let hasSignedCountsGroupByArgs = null;
|
||||
|
||||
const visibilityFiltersWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
AND: [
|
||||
{ deletedAt: null },
|
||||
{
|
||||
OR: [
|
||||
match(options.currentTeamMemberRole)
|
||||
.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: {
|
||||
equals: DocumentVisibility.EVERYONE,
|
||||
},
|
||||
})),
|
||||
{
|
||||
OR: [
|
||||
{ userId: options.userId },
|
||||
{ recipients: { some: { email: options.currentUserEmail } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
ownerCountsWhereInput = {
|
||||
...ownerCountsWhereInput,
|
||||
AND: [
|
||||
...(Array.isArray(visibilityFiltersWhereInput.AND)
|
||||
? visibilityFiltersWhereInput.AND
|
||||
: visibilityFiltersWhereInput.AND
|
||||
? [visibilityFiltersWhereInput.AND]
|
||||
: []),
|
||||
searchFilter,
|
||||
rootPageFilter,
|
||||
folderId ? { folderId } : {},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail) {
|
||||
ownerCountsWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
OR: [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
};
|
||||
|
||||
notSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
|
||||
hasSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: ownerCountsWhereInput,
|
||||
}),
|
||||
notSignedCountsGroupByArgs ? prisma.envelope.groupBy(notSignedCountsGroupByArgs) : [],
|
||||
hasSignedCountsGroupByArgs ? prisma.envelope.groupBy(hasSignedCountsGroupByArgs) : [],
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
@@ -25,12 +26,17 @@ import { prisma } from '@documenso/prisma';
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
@@ -213,45 +219,49 @@ export const resendDocument = async ({
|
||||
}),
|
||||
]);
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: envelope.documentMeta.subject
|
||||
? renderCustomEmailTemplate(
|
||||
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
|
||||
customEmailTemplate,
|
||||
)
|
||||
: emailSubject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Send email outside any transaction to avoid holding a connection
|
||||
// open during network I/O.
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: envelope.documentMeta.subject
|
||||
? renderCustomEmailTemplate(
|
||||
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
|
||||
customEmailTemplate,
|
||||
)
|
||||
: emailSubject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
return envelope;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import type { Envelope, Recipient, User } from '@prisma/client';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { DocumentStatus, DocumentVisibility, EnvelopeType, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
buildTeamWhereQuery,
|
||||
formatDocumentsPath,
|
||||
getHighestTeamRoleInGroup,
|
||||
} from '@documenso/lib/utils/teams';
|
||||
import { formatDocumentsPath, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { getUserTeamGroups } from '../team/get-user-team-groups';
|
||||
|
||||
export type SearchDocumentsWithKeywordOptions = {
|
||||
query: string;
|
||||
@@ -23,105 +19,83 @@ export const searchDocumentsWithKeyword = async ({
|
||||
userId,
|
||||
limit = 20,
|
||||
}: SearchDocumentsWithKeywordOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
if (!query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [user, teamGroupsByTeamId] = await Promise.all([
|
||||
prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
}),
|
||||
getUserTeamGroups({ userId }),
|
||||
]);
|
||||
|
||||
const teamIds = [...teamGroupsByTeamId.keys()];
|
||||
|
||||
const filters: Prisma.EnvelopeWhereInput[] = [
|
||||
// Documents owned by the user matching title, externalId, or recipient email.
|
||||
{
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
||||
{
|
||||
recipients: {
|
||||
some: { email: { contains: query, mode: 'insensitive' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// Documents where the user is a recipient (completed or pending).
|
||||
{
|
||||
status: { in: [DocumentStatus.COMPLETED, DocumentStatus.PENDING] },
|
||||
recipients: { some: { email: user.email } },
|
||||
title: { contains: query, mode: 'insensitive' },
|
||||
deletedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Team documents the user has access to.
|
||||
if (teamIds.length > 0) {
|
||||
filters.push({
|
||||
teamId: { in: teamIds },
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
||||
{
|
||||
recipients: {
|
||||
some: { email: { contains: query, mode: 'insensitive' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
userId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
userId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
},
|
||||
userId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: DocumentStatus.COMPLETED,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
status: DocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
team: buildTeamWhereQuery({ teamId: undefined, userId }),
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
team: buildTeamWhereQuery({ teamId: undefined, userId }),
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
OR: filters,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
title: true,
|
||||
secondaryId: true,
|
||||
visibility: true,
|
||||
recipients: {
|
||||
select: {
|
||||
email: true,
|
||||
token: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
url: true,
|
||||
teamGroups: {
|
||||
where: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -129,29 +103,24 @@ export const searchDocumentsWithKeyword = async ({
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: limit,
|
||||
// Over-fetch to compensate for post-query visibility filtering on team documents.
|
||||
take: limit * 3,
|
||||
});
|
||||
|
||||
const isOwner = (envelope: Envelope, user: User) => envelope.userId === user.id;
|
||||
|
||||
const getSigningLink = (recipients: Recipient[], user: User) =>
|
||||
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
|
||||
|
||||
const maskedDocuments = envelopes
|
||||
const results = envelopes
|
||||
.filter((envelope) => {
|
||||
if (!envelope.teamId || isOwner(envelope, user)) {
|
||||
if (!envelope.teamId || envelope.userId === user.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamMemberRole = getHighestTeamRoleInGroup(
|
||||
envelope.team.teamGroups.filter((tg) => tg.teamId === envelope.teamId),
|
||||
);
|
||||
const teamGroups = teamGroupsByTeamId.get(envelope.teamId) ?? [];
|
||||
const teamMemberRole = getHighestTeamRoleInGroup(teamGroups);
|
||||
|
||||
if (!teamMemberRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canAccessDocument = match([envelope.visibility, teamMemberRole])
|
||||
return match([envelope.visibility, teamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||
@@ -159,34 +128,29 @@ export const searchDocumentsWithKeyword = async ({
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||
.otherwise(() => false);
|
||||
|
||||
return canAccessDocument;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map((envelope) => {
|
||||
const { recipients, ...documentWithoutRecipient } = envelope;
|
||||
|
||||
let documentPath;
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (isOwner(envelope, user)) {
|
||||
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
|
||||
} else if (envelope.teamId && envelope.team.teamGroups.length > 0) {
|
||||
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
|
||||
let path: string;
|
||||
|
||||
if (
|
||||
envelope.userId === user.id ||
|
||||
(envelope.teamId && teamGroupsByTeamId.has(envelope.teamId))
|
||||
) {
|
||||
path = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
|
||||
} else {
|
||||
documentPath = getSigningLink(recipients, user);
|
||||
const signingToken = envelope.recipients.find((r) => r.email === user.email)?.token;
|
||||
path = `/sign/${signingToken}`;
|
||||
}
|
||||
|
||||
return {
|
||||
...documentWithoutRecipient,
|
||||
team: {
|
||||
id: envelope.teamId,
|
||||
url: envelope.team.url,
|
||||
},
|
||||
path: documentPath,
|
||||
title: envelope.title,
|
||||
path,
|
||||
value: [envelope.id, envelope.title, ...envelope.recipients.map((r) => r.email)].join(' '),
|
||||
};
|
||||
});
|
||||
|
||||
return maskedDocuments;
|
||||
return results;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
|
||||
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
@@ -18,6 +18,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
|
||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '../../constants/direct-templates';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
@@ -159,10 +160,12 @@ export const sendDocument = async ({
|
||||
);
|
||||
|
||||
if (recipientsWithMissingFields.length > 0) {
|
||||
const missingRecipientIds = recipientsWithMissingFields.map((r) => r.id).join(', ');
|
||||
const missingRecipientDescriptions = recipientsWithMissingFields
|
||||
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
|
||||
.join(', ');
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `The following recipients are missing required fields: ${missingRecipientIds}. Signers must have at least one signature field.`,
|
||||
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,7 +208,7 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
|
||||
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField, recipient);
|
||||
|
||||
// Only auto-insert fields if the recipient has not been sent the document yet.
|
||||
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
|
||||
@@ -372,6 +375,7 @@ const injectFormValuesIntoDocument = async (
|
||||
*/
|
||||
export const extractFieldAutoInsertValues = (
|
||||
unknownField: Field,
|
||||
recipient: Pick<Recipient, 'email'>,
|
||||
): { fieldId: number; customText: string } | null => {
|
||||
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
|
||||
|
||||
@@ -384,6 +388,18 @@ export const extractFieldAutoInsertValues = (
|
||||
const field = parsedField.data;
|
||||
const fieldId = unknownField.id;
|
||||
|
||||
// Auto insert email fields if the recipient has a valid email.
|
||||
if (
|
||||
field.type === FieldType.EMAIL &&
|
||||
isRecipientEmailValidForSending(recipient) &&
|
||||
recipient.email !== DIRECT_TEMPLATE_RECIPIENT_EMAIL
|
||||
) {
|
||||
return {
|
||||
fieldId,
|
||||
customText: recipient.email,
|
||||
};
|
||||
}
|
||||
|
||||
// Auto insert text fields with prefilled values.
|
||||
if (field.type === FieldType.TEXT) {
|
||||
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EnvelopeType, ReadStatus, SendStatus } from '@prisma/client';
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
import { EnvelopeType, ReadStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@@ -70,6 +69,8 @@ export const viewedDocument = async ({
|
||||
// This handles cases where distribution is done manually
|
||||
sendStatus: SendStatus.SENT,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
// Only set sentAt if not already set (email may have been sent before they opened).
|
||||
...(!recipient.sentAt ? { sentAt: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,6 +94,9 @@ export const viewedDocument = async ({
|
||||
});
|
||||
});
|
||||
|
||||
// Don't schedule reminders for manually distributed documents —
|
||||
// there's no email pathway to send them through.
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: recipient.envelopeId,
|
||||
|
||||
Reference in New Issue
Block a user