Merge branch 'main' into refactor/trpc-error-formatter

This commit is contained in:
Ephraim Duncan
2026-09-24 11:29:49 +00:00
committed by GitHub
356 changed files with 21952 additions and 11581 deletions
@@ -1,4 +1,7 @@
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import { ZCreateApiTokenRequestSchema, ZCreateApiTokenResponseSchema } from './create-api-token.types';
@@ -15,10 +18,26 @@ export const createApiTokenRoute = authenticatedProcedure
},
});
return await createApiToken({
const createdToken = await createApiToken({
userId: ctx.user.id,
teamId,
tokenName,
expiresIn: expirationDate,
});
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: { id: teamId },
select: { organisationId: true },
});
captureServerEvent({
event: 'App: API Token Created',
userId: ctx.user.id,
teamId,
organisationId: team?.organisationId,
});
});
return createdToken;
});
@@ -9,6 +9,7 @@ export const ZGetApiTokensResponseSchema = z.array(
name: true,
createdAt: true,
expires: true,
lastUsedAt: true,
}),
);
@@ -0,0 +1,19 @@
import { getUserAuthMethods } from '@documenso/lib/server-only/user/get-user-auth-methods';
import { authenticatedProcedure } from '../trpc';
import { ZGetAuthMethodsResponseSchema } from './get-auth-methods.types';
/**
* Get the sign in methods available to the current user.
*/
export const getAuthMethodsRoute = authenticatedProcedure
.output(ZGetAuthMethodsResponseSchema)
.query(async ({ ctx }) => {
const authMethods = await getUserAuthMethods({
userId: ctx.user.id,
});
return {
authMethods,
};
});
@@ -0,0 +1,8 @@
import { ZUserAuthMethodSchema } from '@documenso/lib/types/user-auth-method';
import { z } from 'zod';
export const ZGetAuthMethodsResponseSchema = z.object({
authMethods: z.array(ZUserAuthMethodSchema),
});
export type TGetAuthMethodsResponse = z.infer<typeof ZGetAuthMethodsResponseSchema>;
@@ -5,9 +5,11 @@ import { createPasskeyRegistrationOptionsRoute } from './create-passkey-registra
import { createPasskeySigninOptionsRoute } from './create-passkey-signin-options';
import { deletePasskeyRoute } from './delete-passkey';
import { findPasskeysRoute } from './find-passkeys';
import { getAuthMethodsRoute } from './get-auth-methods';
import { updatePasskeyRoute } from './update-passkey';
export const authRouter = router({
getAuthMethods: getAuthMethodsRoute,
passkey: router({
create: createPasskeyRoute,
createAuthenticationOptions: createPasskeyAuthenticationOptionsRoute,
@@ -5,13 +5,13 @@ import type { Envelope, Prisma } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import { ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types';
import { type TInboxStatus, ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types';
export const findInboxRoute = authenticatedProcedure
.input(ZFindInboxRequestSchema)
.output(ZFindInboxResponseSchema)
.query(async ({ input, ctx }) => {
const { page, perPage } = input;
const { page, perPage, query, status } = input;
const userId = ctx.user.id;
@@ -19,6 +19,8 @@ export const findInboxRoute = authenticatedProcedure
userId,
page,
perPage,
query,
status,
});
return {
@@ -31,13 +33,22 @@ export type FindInboxOptions = {
userId: number;
page?: number;
perPage?: number;
/**
* Case insensitive search against the document title.
*/
query?: string;
/**
* Restrict results to a single status. When omitted, every non-draft status is returned.
*/
status?: TInboxStatus;
orderBy?: {
column: keyof Omit<Envelope, 'envelope'>;
direction: 'asc' | 'desc';
};
};
export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: FindInboxOptions) => {
export const findInbox = async ({ userId, page = 1, perPage = 10, query = '', status, orderBy }: FindInboxOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -50,10 +61,11 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const searchQuery = query.trim();
const whereClause: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
status: {
status: status ?? {
not: DocumentStatus.DRAFT,
},
deletedAt: null,
@@ -67,6 +79,13 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
},
};
if (searchQuery.length > 0) {
whereClause.title = {
contains: searchQuery,
mode: 'insensitive',
};
}
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: whereClause,
@@ -2,12 +2,33 @@
import { ZDocumentManySchema } from '@documenso/lib/types/document';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import type { z } from 'zod';
import { DocumentStatus } from '@prisma/client';
import { z } from 'zod';
export const ZFindInboxRequestSchema = ZFindSearchParamsSchema;
/**
* The statuses that can be filtered by in the inbox.
*
* Every document status except DRAFT, since drafts have not been sent to
* recipients yet and must never be visible in the inbox.
*/
export const INBOX_STATUSES = [
DocumentStatus.PENDING,
DocumentStatus.COMPLETED,
DocumentStatus.REJECTED,
DocumentStatus.CANCELLED,
] as const;
export const ZInboxStatusSchema = z.enum(INBOX_STATUSES);
export type TInboxStatus = z.infer<typeof ZInboxStatusSchema>;
export const ZFindInboxRequestSchema = ZFindSearchParamsSchema.extend({
status: ZInboxStatusSchema.describe('Filter the inbox by document status.').optional(),
});
export const ZFindInboxResponseSchema = ZFindResultResponse.extend({
data: ZDocumentManySchema.array(),
});
export type TFindInboxRequest = z.infer<typeof ZFindInboxRequestSchema>;
export type TFindInboxResponse = z.infer<typeof ZFindInboxResponseSchema>;
@@ -18,6 +18,5 @@ export const embeddingPresignRouter = router({
updateEmbeddingEnvelope: updateEmbeddingEnvelopeRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
// applyMultiSignSignature: applyMultiSignSignatureRoute,
getMultiSignDocument: getMultiSignDocumentRoute,
});
@@ -1,98 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { FieldType, ReadStatus, SigningStatus } from '@prisma/client';
import { procedure } from '../trpc';
import {
ZApplyMultiSignSignatureRequestSchema,
ZApplyMultiSignSignatureResponseSchema,
} from './apply-multi-sign-signature.types';
export const applyMultiSignSignatureRoute = procedure
.input(ZApplyMultiSignSignatureRequestSchema)
.output(ZApplyMultiSignSignatureResponseSchema)
.mutation(async ({ input, ctx: { metadata } }) => {
try {
const { tokens, signature, isBase64 } = input;
// Get all documents and recipients for the tokens
const envelopes = await Promise.all(
tokens.map(async (token) => {
const document = await getDocumentByToken({ token });
const recipient = await getRecipientByToken({ token });
return { document, recipient };
}),
);
// Check if all documents have been viewed
const hasUnviewedDocuments = envelopes.some((envelope) => envelope.recipient.readStatus !== ReadStatus.OPENED);
if (hasUnviewedDocuments) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'All documents must be viewed before signing',
});
}
// If we require action auth we should abort here for now
for (const envelope of envelopes) {
const derivedRecipientActionAuth = extractDocumentAuthMethods({
documentAuth: envelope.document.authOptions,
recipientAuth: envelope.recipient.authOptions,
});
if (
derivedRecipientActionAuth.recipientAccessAuthRequired ||
derivedRecipientActionAuth.recipientActionAuthRequired
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Documents that require additional authentication cannot be multi signed at the moment',
});
}
}
// Sign all signature fields for each document
await Promise.all(
envelopes.map(async (envelope) => {
if (envelope.recipient.signingStatus === SigningStatus.REJECTED) {
return;
}
const signatureFields = await prisma.field.findMany({
where: {
envelopeId: envelope.document.id,
recipientId: envelope.recipient.id,
type: FieldType.SIGNATURE,
inserted: false,
},
});
await Promise.all(
signatureFields.map(async (field) =>
signFieldWithToken({
token: envelope.recipient.token,
fieldId: field.id,
value: signature,
isBase64,
requestMetadata: metadata.requestMetadata,
}),
),
);
}),
);
return { success: true };
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to apply multi-sign signature',
});
}
});
@@ -1,14 +0,0 @@
import { z } from 'zod';
export const ZApplyMultiSignSignatureRequestSchema = z.object({
tokens: z.array(z.string()).min(1, { message: 'At least one token is required' }),
signature: z.string().min(1, { message: 'Signature is required' }),
isBase64: z.boolean().optional().default(false),
});
export const ZApplyMultiSignSignatureResponseSchema = z.object({
success: z.boolean(),
});
export type TApplyMultiSignSignatureRequestSchema = z.infer<typeof ZApplyMultiSignSignatureRequestSchema>;
export type TApplyMultiSignSignatureResponseSchema = z.infer<typeof ZApplyMultiSignSignatureResponseSchema>;
@@ -8,13 +8,14 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/cancel',
summary: 'Cancel envelope',
description: 'Cancel a pending envelope',
tags: ['Envelope'],
},
};
export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
reason: z.string().optional(),
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
});
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -8,6 +8,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { EnvelopeType } from '@prisma/client';
import type { Logger } from 'pino';
import { match, P } from 'ts-pattern';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
import { authenticatedProcedure } from '../trpc';
@@ -148,19 +149,11 @@ export const createEnvelopeRouteCaller = async ({
accessAuth: recipient.accessAuth,
actionAuth: recipient.actionAuth,
fields: recipient.fields?.map((field) => {
let documentDataId: string | undefined;
if (typeof field.identifier === 'string') {
documentDataId = envelopeItems.find((item) => item.title === field.identifier)?.documentDataId;
}
if (typeof field.identifier === 'number') {
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
}
if (field.identifier === undefined) {
documentDataId = envelopeItems.at(0)?.documentDataId;
}
const documentDataId = match(field.identifier)
.with(P.string, (title) => envelopeItems.find((item) => item.title === title)?.documentDataId)
.with(P.number, (index) => envelopeItems.at(index)?.documentDataId)
.with(undefined, () => envelopeItems.at(0)?.documentDataId)
.exhaustive();
if (!documentDataId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -8,12 +8,13 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/delete',
summary: 'Delete envelope',
description: 'Delete an envelope',
tags: ['Envelope'],
},
};
export const ZDeleteEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().describe('The ID of the envelope to delete.'),
});
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -29,7 +29,17 @@ export const updateEnvelopeFieldsRoute = authenticatedProcedure
id: envelopeId,
},
type: null,
fields,
fields: fields.map((field) => ({
id: field.id,
type: field.type,
pageNumber: field.page,
pageX: field.positionX,
pageY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
envelopeItemId: field.envelopeItemId,
})),
requestMetadata: ctx.metadata,
});
@@ -39,8 +39,8 @@ export const signEnvelopeFieldRoute = procedure
const field = await prisma.field.findFirst({
where: {
id: fieldId,
recipient: {
...(recipient.role === RecipientRole.ASSISTANT
recipient:
recipient.role === RecipientRole.ASSISTANT
? {
signingStatus: {
not: SigningStatus.SIGNED,
@@ -52,8 +52,7 @@ export const signEnvelopeFieldRoute = procedure
}
: {
id: recipient.id,
}),
},
},
},
include: {
envelope: {
@@ -12,24 +12,32 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/update',
summary: 'Update envelope',
description: 'Update envelope properties and settings',
tags: ['Envelope'],
},
};
export const ZUpdateEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().describe('The ID of the envelope to update.'),
data: z
.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
templateType: z.nativeEnum(TemplateType).optional(),
globalAccessAuth: z
.array(ZDocumentAccessAuthTypesSchema)
.describe('The authentication methods required to access the envelope.')
.optional(),
globalActionAuth: z
.array(ZDocumentActionAuthTypesSchema)
.describe('The authentication methods required to sign the envelope.')
.optional(),
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
})
.describe('The envelope properties to update.')
.optional(),
meta: ZDocumentMetaUpdateSchema.optional(),
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
});
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
@@ -6,6 +6,7 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { EnvelopeType } from '@prisma/client';
import { match, P } from 'ts-pattern';
import { authenticatedProcedure } from '../trpc';
import { useEnvelopeMeta, ZUseEnvelopeRequestSchema, ZUseEnvelopeResponseSchema } from './use-envelope.types';
@@ -87,20 +88,11 @@ export const useEnvelopeRoute = authenticatedProcedure
// Map custom document data using identifiers
const customDocumentDataMapped = customDocumentData?.map((mapping) => {
let documentDataId: string | undefined;
// Find the uploaded file by identifier
if (typeof mapping.identifier === 'string') {
documentDataId = uploadedFiles.find((file) => file.name === mapping.identifier)?.documentDataId;
}
if (typeof mapping.identifier === 'number') {
documentDataId = uploadedFiles.at(mapping.identifier)?.documentDataId;
}
if (mapping.identifier === undefined) {
documentDataId = uploadedFiles.at(0)?.documentDataId;
}
const documentDataId = match(mapping.identifier)
.with(P.string, (name) => uploadedFiles.find((file) => file.name === name)?.documentDataId)
.with(P.number, (index) => uploadedFiles.at(index)?.documentDataId)
.exhaustive();
if (!documentDataId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -0,0 +1,32 @@
import { getOrganisationAnalyticsDocumentsOverTime } from '@documenso/lib/server-only/organisation/get-organisation-analytics-documents-over-time';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationAnalyticsDocumentsOverTimeRequestSchema,
ZGetOrganisationAnalyticsDocumentsOverTimeResponseSchema,
} from './get-organisation-analytics.types';
export const getOrganisationAnalyticsDocumentsOverTimeRoute = authenticatedProcedure
.input(ZGetOrganisationAnalyticsDocumentsOverTimeRequestSchema)
.output(ZGetOrganisationAnalyticsDocumentsOverTimeResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, range, from, to, timezone } = input;
ctx.logger.info({
input: {
organisationId,
range,
from,
to,
},
});
return await getOrganisationAnalyticsDocumentsOverTime({
organisationId,
range,
from,
to,
timezone,
userId: ctx.user.id,
});
});
@@ -0,0 +1,32 @@
import { getOrganisationAnalyticsOverview } from '@documenso/lib/server-only/organisation/get-organisation-analytics-overview';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationAnalyticsOverviewRequestSchema,
ZGetOrganisationAnalyticsOverviewResponseSchema,
} from './get-organisation-analytics.types';
export const getOrganisationAnalyticsOverviewRoute = authenticatedProcedure
.input(ZGetOrganisationAnalyticsOverviewRequestSchema)
.output(ZGetOrganisationAnalyticsOverviewResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, range, from, to, timezone } = input;
ctx.logger.info({
input: {
organisationId,
range,
from,
to,
},
});
return await getOrganisationAnalyticsOverview({
organisationId,
range,
from,
to,
timezone,
userId: ctx.user.id,
});
});
@@ -0,0 +1,32 @@
import { getOrganisationAnalyticsStatusBreakdown } from '@documenso/lib/server-only/organisation/get-organisation-analytics-status-breakdown';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationAnalyticsStatusBreakdownRequestSchema,
ZGetOrganisationAnalyticsStatusBreakdownResponseSchema,
} from './get-organisation-analytics.types';
export const getOrganisationAnalyticsStatusBreakdownRoute = authenticatedProcedure
.input(ZGetOrganisationAnalyticsStatusBreakdownRequestSchema)
.output(ZGetOrganisationAnalyticsStatusBreakdownResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, range, from, to, timezone } = input;
ctx.logger.info({
input: {
organisationId,
range,
from,
to,
},
});
return await getOrganisationAnalyticsStatusBreakdown({
organisationId,
range,
from,
to,
timezone,
userId: ctx.user.id,
});
});
@@ -0,0 +1,32 @@
import { getOrganisationAnalyticsTeamActivity } from '@documenso/lib/server-only/organisation/get-organisation-analytics-team-activity';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationAnalyticsTeamActivityRequestSchema,
ZGetOrganisationAnalyticsTeamActivityResponseSchema,
} from './get-organisation-analytics.types';
export const getOrganisationAnalyticsTeamActivityRoute = authenticatedProcedure
.input(ZGetOrganisationAnalyticsTeamActivityRequestSchema)
.output(ZGetOrganisationAnalyticsTeamActivityResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, range, from, to, timezone } = input;
ctx.logger.info({
input: {
organisationId,
range,
from,
to,
},
});
return await getOrganisationAnalyticsTeamActivity({
organisationId,
range,
from,
to,
timezone,
userId: ctx.user.id,
});
});
@@ -0,0 +1,34 @@
import { getOrganisationAnalyticsTemplateUsage } from '@documenso/lib/server-only/organisation/get-organisation-analytics-template-usage';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationAnalyticsTemplateUsageRequestSchema,
ZGetOrganisationAnalyticsTemplateUsageResponseSchema,
} from './get-organisation-analytics.types';
export const getOrganisationAnalyticsTemplateUsageRoute = authenticatedProcedure
.input(ZGetOrganisationAnalyticsTemplateUsageRequestSchema)
.output(ZGetOrganisationAnalyticsTemplateUsageResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, range, from, to, timezone, limit } = input;
ctx.logger.info({
input: {
organisationId,
range,
from,
to,
limit,
},
});
return await getOrganisationAnalyticsTemplateUsage({
organisationId,
range,
from,
to,
timezone,
limit,
userId: ctx.user.id,
});
});
@@ -0,0 +1,161 @@
import { z } from 'zod';
import {
ZAnalyticsRangeFieldsSchema,
ZGetTeamAnalyticsDocumentsOverTimeResponseSchema,
ZGetTeamAnalyticsStatusBreakdownResponseSchema,
ZTeamAnalyticsResolvedRangeSchema,
} from '../team-router/get-team-analytics.types';
/**
* Base request shared by every organisation analytics procedure.
*
* Organisation analytics are restricted to organisation ADMINs. The organisation
* ADMIN role authorises organisation-wide visibility: the internal ADMIN group is
* attached to every team by `createTeam` and cannot be detached, so every document
* in the organisation is in scope and no per-document visibility filtering is applied.
*/
export const ZOrganisationAnalyticsRequestSchema = ZAnalyticsRangeFieldsSchema.extend({
organisationId: z.string().min(1),
});
export type TOrganisationAnalyticsRequest = z.infer<typeof ZOrganisationAnalyticsRequestSchema>;
const ZCountSchema = z.number().int().nonnegative();
const ZAnalyticsTeamSchema = z.object({
id: z.number().int().positive(),
name: z.string(),
url: z.string(),
avatarImageId: z.string().nullable(),
});
// -----------------------------------------------------------------------------
// organisation.analytics.getOverview
// -----------------------------------------------------------------------------
export const ZGetOrganisationAnalyticsOverviewRequestSchema = ZOrganisationAnalyticsRequestSchema;
export const ZGetOrganisationAnalyticsOverviewResponseSchema = z.object({
range: ZTeamAnalyticsResolvedRangeSchema,
/** Documents whose DOCUMENT_SENT event falls inside the window. */
sent: z.object({
current: ZCountSchema,
previous: ZCountSchema,
}),
/** Of documents sent in the window, how many are currently COMPLETED. */
completionRate: z.object({
completed: ZCountSchema,
sent: ZCountSchema,
rate: z.number().min(0).max(100).nullable(),
previousRate: z.number().min(0).max(100).nullable(),
}),
teams: z.object({
/** Total number of teams in the organisation. */
total: ZCountSchema,
/** Teams with at least one document sent in the window. */
active: ZCountSchema,
}),
});
export type TGetOrganisationAnalyticsOverviewRequest = z.infer<typeof ZGetOrganisationAnalyticsOverviewRequestSchema>;
export type TGetOrganisationAnalyticsOverviewResponse = z.infer<typeof ZGetOrganisationAnalyticsOverviewResponseSchema>;
// -----------------------------------------------------------------------------
// organisation.analytics.getDocumentsOverTime
// -----------------------------------------------------------------------------
export const ZGetOrganisationAnalyticsDocumentsOverTimeRequestSchema = ZOrganisationAnalyticsRequestSchema;
export const ZGetOrganisationAnalyticsDocumentsOverTimeResponseSchema =
ZGetTeamAnalyticsDocumentsOverTimeResponseSchema;
export type TGetOrganisationAnalyticsDocumentsOverTimeRequest = z.infer<
typeof ZGetOrganisationAnalyticsDocumentsOverTimeRequestSchema
>;
export type TGetOrganisationAnalyticsDocumentsOverTimeResponse = z.infer<
typeof ZGetOrganisationAnalyticsDocumentsOverTimeResponseSchema
>;
// -----------------------------------------------------------------------------
// organisation.analytics.getStatusBreakdown
// -----------------------------------------------------------------------------
export const ZGetOrganisationAnalyticsStatusBreakdownRequestSchema = ZOrganisationAnalyticsRequestSchema;
export const ZGetOrganisationAnalyticsStatusBreakdownResponseSchema = ZGetTeamAnalyticsStatusBreakdownResponseSchema;
export type TGetOrganisationAnalyticsStatusBreakdownRequest = z.infer<
typeof ZGetOrganisationAnalyticsStatusBreakdownRequestSchema
>;
export type TGetOrganisationAnalyticsStatusBreakdownResponse = z.infer<
typeof ZGetOrganisationAnalyticsStatusBreakdownResponseSchema
>;
// -----------------------------------------------------------------------------
// organisation.analytics.getTemplateUsage
// -----------------------------------------------------------------------------
export const ZGetOrganisationAnalyticsTemplateUsageRequestSchema = ZOrganisationAnalyticsRequestSchema.extend({
limit: z.number().int().min(1).max(20).default(5),
});
/** Templates across the organisation ranked by documents created from them inside the window. */
export const ZGetOrganisationAnalyticsTemplateUsageResponseSchema = z.object({
range: ZTeamAnalyticsResolvedRangeSchema,
templates: z.array(
z.object({
/** Legacy numeric template id (Envelope.templateId on documents). */
id: z.number().int().positive(),
/** Envelope id of the template, null when the template was deleted. */
envelopeId: z.string().nullable(),
title: z.string().nullable(),
updatedAt: z.date().nullable(),
/** Team the template belongs to, null when the template was deleted. */
team: ZAnalyticsTeamSchema.nullable(),
count: ZCountSchema,
}),
),
});
export type TGetOrganisationAnalyticsTemplateUsageRequest = z.infer<
typeof ZGetOrganisationAnalyticsTemplateUsageRequestSchema
>;
export type TGetOrganisationAnalyticsTemplateUsageResponse = z.infer<
typeof ZGetOrganisationAnalyticsTemplateUsageResponseSchema
>;
// -----------------------------------------------------------------------------
// organisation.analytics.getTeamActivity
// -----------------------------------------------------------------------------
export const ZGetOrganisationAnalyticsTeamActivityRequestSchema = ZOrganisationAnalyticsRequestSchema;
/**
* Per-team activity for every team in the organisation. Teams with no activity
* are included with zeros. Sorted by sent desc, then name asc.
*/
export const ZGetOrganisationAnalyticsTeamActivityResponseSchema = z.object({
range: ZTeamAnalyticsResolvedRangeSchema,
teams: z.array(
ZAnalyticsTeamSchema.extend({
/** Documents sent by this team inside the window. */
sent: ZCountSchema,
/** Of `sent`, currently COMPLETED. */
completed: ZCountSchema,
/** Of `sent`, currently PENDING. */
pending: ZCountSchema,
/** 0-100, null when `sent` is 0. */
completionRate: z.number().min(0).max(100).nullable(),
/** Most recent send inside the window, null when none. */
lastActiveAt: z.date().nullable(),
}),
),
});
export type TGetOrganisationAnalyticsTeamActivityRequest = z.infer<
typeof ZGetOrganisationAnalyticsTeamActivityRequestSchema
>;
export type TGetOrganisationAnalyticsTeamActivityResponse = z.infer<
typeof ZGetOrganisationAnalyticsTeamActivityResponseSchema
>;
@@ -13,6 +13,11 @@ import { findOrganisationGroupsRoute } from './find-organisation-groups';
import { findOrganisationMemberInvitesRoute } from './find-organisation-member-invites';
import { findOrganisationMembersRoute } from './find-organisation-members';
import { getOrganisationRoute } from './get-organisation';
import { getOrganisationAnalyticsDocumentsOverTimeRoute } from './get-organisation-analytics-documents-over-time';
import { getOrganisationAnalyticsOverviewRoute } from './get-organisation-analytics-overview';
import { getOrganisationAnalyticsStatusBreakdownRoute } from './get-organisation-analytics-status-breakdown';
import { getOrganisationAnalyticsTeamActivityRoute } from './get-organisation-analytics-team-activity';
import { getOrganisationAnalyticsTemplateUsageRoute } from './get-organisation-analytics-template-usage';
import { getOrganisationMemberInvitesRoute } from './get-organisation-member-invites';
import { getOrganisationQuotaFlagsRoute } from './get-organisation-quota-flags';
import { getOrganisationSessionRoute } from './get-organisation-session';
@@ -33,6 +38,13 @@ export const organisationRouter = router({
update: updateOrganisationRoute,
delete: deleteOrganisationRoute,
leave: leaveOrganisationRoute,
analytics: {
getOverview: getOrganisationAnalyticsOverviewRoute,
getDocumentsOverTime: getOrganisationAnalyticsDocumentsOverTimeRoute,
getStatusBreakdown: getOrganisationAnalyticsStatusBreakdownRoute,
getTemplateUsage: getOrganisationAnalyticsTemplateUsageRoute,
getTeamActivity: getOrganisationAnalyticsTeamActivityRoute,
},
member: {
find: findOrganisationMembersRoute,
update: updateOrganisationMemberRoute,
@@ -603,7 +603,7 @@ export const recipientRouter = router({
// can't complete via this route — they go through the CSC sync sign
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
// for the credential-scope OAuth round-trip.
const envelope = await prisma.envelope.findFirstOrThrow({
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
recipients: { some: { token } },
@@ -611,6 +611,16 @@ export const recipientRouter = router({
select: { signatureLevel: true, internalVersion: true },
});
// The most common cause is a stale signing page: the document was
// deleted, or the recipient was removed, after the link was opened.
// Surface a NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
if (isTspEnvelope(envelope)) {
return await prepareCscRecipientSigning({
recipientToken: token,
@@ -8,7 +8,7 @@ export const findTeamsRoute = authenticatedProcedure
.input(ZFindTeamsRequestSchema)
.output(ZFindTeamsResponseSchema)
.query(async ({ ctx, input }) => {
const { organisationId } = input;
const { organisationId, query, page, perPage } = input;
const { user } = ctx;
ctx.logger.info({
@@ -17,5 +17,5 @@ export const findTeamsRoute = authenticatedProcedure
},
});
return findTeams({ userId: user.id, organisationId });
return findTeams({ userId: user.id, organisationId, query, page, perPage });
});
@@ -0,0 +1,33 @@
import { getTeamAnalyticsDocumentsOverTime } from '@documenso/lib/server-only/team/get-team-analytics-documents-over-time';
import { authenticatedProcedure } from '../trpc';
import {
ZGetTeamAnalyticsDocumentsOverTimeRequestSchema,
ZGetTeamAnalyticsDocumentsOverTimeResponseSchema,
} from './get-team-analytics.types';
export const getTeamAnalyticsDocumentsOverTimeRoute = authenticatedProcedure
.input(ZGetTeamAnalyticsDocumentsOverTimeRequestSchema)
.output(ZGetTeamAnalyticsDocumentsOverTimeResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, range, from, to, timezone } = input;
ctx.logger.info({
input: {
teamId,
range,
from,
to,
},
});
return await getTeamAnalyticsDocumentsOverTime({
teamId,
range,
from,
to,
timezone,
userId: ctx.user.id,
userEmail: ctx.user.email,
});
});
@@ -0,0 +1,33 @@
import { getTeamAnalyticsMemberActivity } from '@documenso/lib/server-only/team/get-team-analytics-member-activity';
import { authenticatedProcedure } from '../trpc';
import {
ZGetTeamAnalyticsMemberActivityRequestSchema,
ZGetTeamAnalyticsMemberActivityResponseSchema,
} from './get-team-analytics.types';
export const getTeamAnalyticsMemberActivityRoute = authenticatedProcedure
.input(ZGetTeamAnalyticsMemberActivityRequestSchema)
.output(ZGetTeamAnalyticsMemberActivityResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, range, from, to, timezone } = input;
ctx.logger.info({
input: {
teamId,
range,
from,
to,
},
});
return await getTeamAnalyticsMemberActivity({
teamId,
range,
from,
to,
timezone,
userId: ctx.user.id,
userEmail: ctx.user.email,
});
});
@@ -0,0 +1,33 @@
import { getTeamAnalyticsOverview } from '@documenso/lib/server-only/team/get-team-analytics-overview';
import { authenticatedProcedure } from '../trpc';
import {
ZGetTeamAnalyticsOverviewRequestSchema,
ZGetTeamAnalyticsOverviewResponseSchema,
} from './get-team-analytics.types';
export const getTeamAnalyticsOverviewRoute = authenticatedProcedure
.input(ZGetTeamAnalyticsOverviewRequestSchema)
.output(ZGetTeamAnalyticsOverviewResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, range, from, to, timezone } = input;
ctx.logger.info({
input: {
teamId,
range,
from,
to,
},
});
return await getTeamAnalyticsOverview({
teamId,
range,
from,
to,
timezone,
userId: ctx.user.id,
userEmail: ctx.user.email,
});
});
@@ -0,0 +1,33 @@
import { getTeamAnalyticsStatusBreakdown } from '@documenso/lib/server-only/team/get-team-analytics-status-breakdown';
import { authenticatedProcedure } from '../trpc';
import {
ZGetTeamAnalyticsStatusBreakdownRequestSchema,
ZGetTeamAnalyticsStatusBreakdownResponseSchema,
} from './get-team-analytics.types';
export const getTeamAnalyticsStatusBreakdownRoute = authenticatedProcedure
.input(ZGetTeamAnalyticsStatusBreakdownRequestSchema)
.output(ZGetTeamAnalyticsStatusBreakdownResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, range, from, to, timezone } = input;
ctx.logger.info({
input: {
teamId,
range,
from,
to,
},
});
return await getTeamAnalyticsStatusBreakdown({
teamId,
range,
from,
to,
timezone,
userId: ctx.user.id,
userEmail: ctx.user.email,
});
});
@@ -0,0 +1,35 @@
import { getTeamAnalyticsTemplateUsage } from '@documenso/lib/server-only/team/get-team-analytics-template-usage';
import { authenticatedProcedure } from '../trpc';
import {
ZGetTeamAnalyticsTemplateUsageRequestSchema,
ZGetTeamAnalyticsTemplateUsageResponseSchema,
} from './get-team-analytics.types';
export const getTeamAnalyticsTemplateUsageRoute = authenticatedProcedure
.input(ZGetTeamAnalyticsTemplateUsageRequestSchema)
.output(ZGetTeamAnalyticsTemplateUsageResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, range, from, to, timezone, limit } = input;
ctx.logger.info({
input: {
teamId,
range,
from,
to,
limit,
},
});
return await getTeamAnalyticsTemplateUsage({
teamId,
range,
from,
to,
timezone,
limit,
userId: ctx.user.id,
userEmail: ctx.user.email,
});
});
@@ -0,0 +1,198 @@
import { IANAZone } from 'luxon';
import { z } from 'zod';
/** Preset date ranges supported by analytics, plus a custom [from, to] window. */
export const ZTeamAnalyticsRangeSchema = z.enum(['7d', '30d', '90d', '12m', 'custom']);
export type TTeamAnalyticsRange = z.infer<typeof ZTeamAnalyticsRangeSchema>;
/** Calendar date in the request timezone, formatted yyyy-MM-dd. */
export const ZAnalyticsDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected yyyy-MM-dd');
/** How far back a custom range may start: one year and one day before today. */
export const ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK = { years: 1, days: 1 } as const;
/**
* Range fields shared by team and organisation analytics requests.
*
* `from` and `to` are inclusive calendar dates and are required when `range` is
* `custom`, ignored otherwise. Combined validation happens in the range resolver so
* this object stays extendable.
*/
export const ZAnalyticsRangeFieldsSchema = z.object({
range: ZTeamAnalyticsRangeSchema.default('30d'),
from: ZAnalyticsDateSchema.optional(),
to: ZAnalyticsDateSchema.optional(),
/** IANA timezone used to resolve day boundaries and buckets. */
timezone: z
.string()
.min(1)
.refine((timezone) => IANAZone.isValidZone(timezone), { message: 'Invalid timezone' })
.default('UTC'),
});
export type TAnalyticsRangeFields = z.infer<typeof ZAnalyticsRangeFieldsSchema>;
/** Base request shared by every team analytics procedure. */
export const ZTeamAnalyticsRequestSchema = ZAnalyticsRangeFieldsSchema.extend({
teamId: z.number().int().positive(),
});
export type TTeamAnalyticsRequest = z.infer<typeof ZTeamAnalyticsRequestSchema>;
const ZCountSchema = z.number().int().nonnegative();
/** Resolved half-open date window, plus the equally sized window immediately before it. */
export const ZTeamAnalyticsResolvedRangeSchema = z.object({
range: ZTeamAnalyticsRangeSchema,
/** Inclusive calendar bounds of the window in the request timezone, yyyy-MM-dd. */
from: ZAnalyticsDateSchema,
to: ZAnalyticsDateSchema,
timezone: z.string(),
start: z.date(),
end: z.date(),
previousStart: z.date(),
previousEnd: z.date(),
bucket: z.enum(['day', 'month']),
});
export type TTeamAnalyticsResolvedRange = z.infer<typeof ZTeamAnalyticsResolvedRangeSchema>;
// -----------------------------------------------------------------------------
// team.analytics.getOverview
// -----------------------------------------------------------------------------
export const ZGetTeamAnalyticsOverviewRequestSchema = ZTeamAnalyticsRequestSchema;
export const ZGetTeamAnalyticsOverviewResponseSchema = z.object({
range: ZTeamAnalyticsResolvedRangeSchema,
/** Documents whose first DOCUMENT_SENT event falls inside the window. */
sent: z.object({
current: ZCountSchema,
previous: ZCountSchema,
}),
/** Of documents sent in the window, how many are currently COMPLETED. */
completionRate: z.object({
completed: ZCountSchema,
sent: ZCountSchema,
/** 0-100, null when nothing was sent. */
rate: z.number().min(0).max(100).nullable(),
/** Previous window rate, null when nothing was sent then. */
previousRate: z.number().min(0).max(100).nullable(),
}),
members: z.object({
/** Total number of users who are members of the team. */
total: ZCountSchema,
/** Distinct team members who sent at least one visible document in the window. */
active: ZCountSchema,
}),
});
export type TGetTeamAnalyticsOverviewRequest = z.infer<typeof ZGetTeamAnalyticsOverviewRequestSchema>;
export type TGetTeamAnalyticsOverviewResponse = z.infer<typeof ZGetTeamAnalyticsOverviewResponseSchema>;
// -----------------------------------------------------------------------------
// team.analytics.getDocumentsOverTime
// -----------------------------------------------------------------------------
export const ZGetTeamAnalyticsDocumentsOverTimeRequestSchema = ZTeamAnalyticsRequestSchema;
export const ZGetTeamAnalyticsDocumentsOverTimeResponseSchema = z.object({
range: ZTeamAnalyticsResolvedRangeSchema,
total: ZCountSchema,
/** Zero-filled, ordered ascending. `date` is the bucket start as yyyy-MM-dd in the request timezone. */
points: z.array(
z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
count: ZCountSchema,
}),
),
});
export type TGetTeamAnalyticsDocumentsOverTimeRequest = z.infer<typeof ZGetTeamAnalyticsDocumentsOverTimeRequestSchema>;
export type TGetTeamAnalyticsDocumentsOverTimeResponse = z.infer<
typeof ZGetTeamAnalyticsDocumentsOverTimeResponseSchema
>;
// -----------------------------------------------------------------------------
// team.analytics.getStatusBreakdown
// -----------------------------------------------------------------------------
export const ZGetTeamAnalyticsStatusBreakdownRequestSchema = ZTeamAnalyticsRequestSchema;
/** Current status of documents created inside the window. */
export const ZGetTeamAnalyticsStatusBreakdownResponseSchema = z.object({
range: ZTeamAnalyticsResolvedRangeSchema,
total: ZCountSchema,
draft: ZCountSchema,
pending: ZCountSchema,
completed: ZCountSchema,
rejected: ZCountSchema,
cancelled: ZCountSchema,
});
export type TGetTeamAnalyticsStatusBreakdownRequest = z.infer<typeof ZGetTeamAnalyticsStatusBreakdownRequestSchema>;
export type TGetTeamAnalyticsStatusBreakdownResponse = z.infer<typeof ZGetTeamAnalyticsStatusBreakdownResponseSchema>;
// -----------------------------------------------------------------------------
// team.analytics.getTemplateUsage
// -----------------------------------------------------------------------------
export const ZGetTeamAnalyticsTemplateUsageRequestSchema = ZTeamAnalyticsRequestSchema.extend({
limit: z.number().int().min(1).max(20).default(5),
});
/** Templates ranked by the number of visible documents created from them inside the window. */
export const ZGetTeamAnalyticsTemplateUsageResponseSchema = z.object({
range: ZTeamAnalyticsResolvedRangeSchema,
templates: z.array(
z.object({
/** Legacy numeric template id (Envelope.templateId on documents). */
id: z.number().int().positive(),
/** Envelope id of the template, null when the template was deleted or is not visible to the caller. */
envelopeId: z.string().nullable(),
title: z.string().nullable(),
updatedAt: z.date().nullable(),
count: ZCountSchema,
}),
),
});
export type TGetTeamAnalyticsTemplateUsageRequest = z.infer<typeof ZGetTeamAnalyticsTemplateUsageRequestSchema>;
export type TGetTeamAnalyticsTemplateUsageResponse = z.infer<typeof ZGetTeamAnalyticsTemplateUsageResponseSchema>;
// -----------------------------------------------------------------------------
// team.analytics.getMemberActivity
// -----------------------------------------------------------------------------
export const ZGetTeamAnalyticsMemberActivityRequestSchema = ZTeamAnalyticsRequestSchema;
/**
* Per-member activity for every current team member, scoped to documents the
* caller can see. Members with no visible activity are included with zeros.
* Sorted by sent desc, then name asc.
*/
export const ZGetTeamAnalyticsMemberActivityResponseSchema = z.object({
range: ZTeamAnalyticsResolvedRangeSchema,
members: z.array(
z.object({
userId: z.number().int().positive(),
name: z.string().nullable(),
email: z.string(),
avatarImageId: z.string().nullable(),
/** Visible documents first sent by this member inside the window. */
sent: ZCountSchema,
/** Of `sent`, currently COMPLETED. */
completed: ZCountSchema,
/** Of `sent`, currently PENDING. */
pending: ZCountSchema,
/** 0-100, null when `sent` is 0. */
completionRate: z.number().min(0).max(100).nullable(),
/** Most recent send inside the window, null when none. */
lastActiveAt: z.date().nullable(),
}),
),
});
export type TGetTeamAnalyticsMemberActivityRequest = z.infer<typeof ZGetTeamAnalyticsMemberActivityRequestSchema>;
export type TGetTeamAnalyticsMemberActivityResponse = z.infer<typeof ZGetTeamAnalyticsMemberActivityResponseSchema>;
@@ -16,6 +16,11 @@ import { findTeamGroupsRoute } from './find-team-groups';
import { findTeamMembersRoute } from './find-team-members';
import { findTeamsRoute } from './find-teams';
import { getTeamRoute } from './get-team';
import { getTeamAnalyticsDocumentsOverTimeRoute } from './get-team-analytics-documents-over-time';
import { getTeamAnalyticsMemberActivityRoute } from './get-team-analytics-member-activity';
import { getTeamAnalyticsOverviewRoute } from './get-team-analytics-overview';
import { getTeamAnalyticsStatusBreakdownRoute } from './get-team-analytics-status-breakdown';
import { getTeamAnalyticsTemplateUsageRoute } from './get-team-analytics-template-usage';
import { getTeamMembersRoute } from './get-team-members';
import {
ZCreateTeamEmailVerificationMutationSchema,
@@ -36,6 +41,13 @@ export const teamRouter = router({
create: createTeamRoute,
update: updateTeamRoute,
delete: deleteTeamRoute,
analytics: {
getOverview: getTeamAnalyticsOverviewRoute,
getDocumentsOverTime: getTeamAnalyticsDocumentsOverTimeRoute,
getStatusBreakdown: getTeamAnalyticsStatusBreakdownRoute,
getTemplateUsage: getTeamAnalyticsTemplateUsageRoute,
getMemberActivity: getTeamAnalyticsMemberActivityRoute,
},
member: {
find: findTeamMembersRoute,
getMany: getTeamMembersRoute,
@@ -0,0 +1,39 @@
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { mapEnvelopeToTemplateMany } from '@documenso/lib/utils/templates';
import { authenticatedProcedure } from '../trpc';
import {
ZFindTemplatesInternalRequestSchema,
ZFindTemplatesInternalResponseSchema,
} from './find-templates-internal.types';
export const findTemplatesInternalRoute = authenticatedProcedure
.input(ZFindTemplatesInternalRequestSchema)
.output(ZFindTemplatesInternalResponseSchema)
.query(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { query, type, folderId, page, perPage, ownerIds } = input;
ctx.logger.info({
input: {
folderId,
},
});
const result = await findTemplates({
userId: user.id,
teamId,
query,
type,
folderId,
page,
perPage,
ownerIds,
});
return {
...result,
data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
};
});
@@ -0,0 +1,12 @@
import { z } from 'zod';
import { ZFindTemplatesRequestSchema, ZFindTemplatesResponseSchema } from './schema';
export const ZFindTemplatesInternalRequestSchema = ZFindTemplatesRequestSchema.extend({
ownerIds: z.array(z.number()).optional(),
});
export const ZFindTemplatesInternalResponseSchema = ZFindTemplatesResponseSchema;
export type TFindTemplatesInternalRequest = z.infer<typeof ZFindTemplatesInternalRequestSchema>;
export type TFindTemplatesInternalResponse = z.infer<typeof ZFindTemplatesInternalResponseSchema>;
+43 -57
View File
@@ -1,6 +1,7 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
@@ -21,17 +22,19 @@ import { findTemplates } from '@documenso/lib/server-only/template/find-template
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates';
import { mapEnvelopeToTemplateLite, mapEnvelopeToTemplateMany } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import type { Envelope } from '@prisma/client';
import { DocumentDataType, EnvelopeType } from '@prisma/client';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
import { findTemplatesInternalRoute } from './find-templates-internal';
import { getTemplatesByIdsRoute } from './get-templates-by-ids';
import {
ZBulkSendTemplateMutationSchema,
@@ -98,35 +101,15 @@ export const templateRouter = router({
// Remapping for backwards compatibility.
return {
...result,
data: result.data.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
return {
id: legacyTemplateId,
envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
title: envelope.title,
userId: envelope.userId,
teamId: envelope.teamId,
authOptions: envelope.authOptions,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
publicTitle: envelope.publicTitle,
publicDescription: envelope.publicDescription,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
team: envelope.team,
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
};
}),
data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
};
}),
/**
* @private
*/
findTemplatesInternal: findTemplatesInternalRoute,
/**
* @private
*/
@@ -145,32 +128,7 @@ export const templateRouter = router({
// Remapping for backwards compatibility.
return {
...result,
data: result.data.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
return {
id: legacyTemplateId,
envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
title: envelope.title,
userId: envelope.userId,
teamId: envelope.teamId,
authOptions: envelope.authOptions,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
publicTitle: envelope.publicTitle,
publicDescription: envelope.publicDescription,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
team: envelope.team,
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
};
}),
data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
};
}),
@@ -744,7 +702,7 @@ export const templateRouter = router({
});
}
return await createTemplateDirectLink({
const directLink = await createTemplateDirectLink({
userId,
teamId,
id: {
@@ -753,6 +711,25 @@ export const templateRouter = router({
},
directRecipientId,
});
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: { id: template.teamId },
select: { organisationId: true },
});
captureServerEvent({
event: 'App: Template Direct Link Enabled',
userId,
teamId: template.teamId,
organisationId: team?.organisationId,
properties: {
envelopeId: template.envelopeId,
},
});
});
return directLink;
}),
/**
@@ -857,6 +834,15 @@ export const templateRouter = router({
});
}
const csvValidationResult = validateBulkSendCsv({
csvContent: csv,
recipientCount: template.recipients.length,
});
if (!csvValidationResult.success) {
return { success: false as const, error: csvValidationResult.error };
}
await jobs.triggerJob({
name: 'internal.bulk-send-template',
payload: {
@@ -869,6 +855,6 @@ export const templateRouter = router({
},
});
return { success: true };
return { success: true as const };
}),
});
+23 -1
View File
@@ -1,9 +1,12 @@
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook';
import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-webhook-by-id';
import { editWebhook } from '@documenso/lib/server-only/webhooks/edit-webhook';
import { getWebhookById } from '@documenso/lib/server-only/webhooks/get-webhook-by-id';
import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-team-id';
import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure, router } from '../trpc';
import { findWebhookCallsRoute } from './find-webhook-calls';
@@ -51,7 +54,7 @@ export const webhookRouter = router({
createWebhook: authenticatedProcedure.input(ZCreateWebhookRequestSchema).mutation(async ({ input, ctx }) => {
const { enabled, eventTriggers, secret, webhookUrl } = input;
return await createWebhook({
const webhook = await createWebhook({
enabled,
secret,
webhookUrl,
@@ -59,6 +62,25 @@ export const webhookRouter = router({
teamId: ctx.teamId,
userId: ctx.user.id,
});
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: { id: ctx.teamId },
select: { organisationId: true },
});
captureServerEvent({
event: 'App: Webhook Created',
userId: ctx.user.id,
teamId: ctx.teamId,
organisationId: team?.organisationId,
properties: {
triggerCount: eventTriggers.length,
},
});
});
return webhook;
}),
deleteWebhook: authenticatedProcedure.input(ZDeleteWebhookRequestSchema).mutation(async ({ input, ctx }) => {