Merge remote-tracking branch 'origin/main' into pr-2468

# Conflicts:
#	packages/lib/types/document-audit-logs.ts
#	packages/lib/utils/document-audit-logs.ts
#	packages/prisma/schema.prisma
This commit is contained in:
ephraimduncan
2026-07-02 06:48:23 +00:00
584 changed files with 38071 additions and 6339 deletions
@@ -1,7 +1,7 @@
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
import { internalClaims } from '@documenso/lib/types/subscription';
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { OrganisationType } from '@prisma/client';
import { adminProcedure } from '../trpc';
import {
ZCreateAdminOrganisationRequestSchema,
@@ -20,11 +20,13 @@ export const createAdminOrganisationRoute = adminProcedure
},
});
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
const organisation = await createOrganisation({
userId: ownerUserId,
name: data.name,
type: OrganisationType.ORGANISATION,
claim: internalClaims.free,
claim: freeSubscriptionClaim,
});
return {
@@ -10,7 +10,21 @@ export const createSubscriptionClaimRoute = adminProcedure
.input(ZCreateSubscriptionClaimRequestSchema)
.output(ZCreateSubscriptionClaimResponseSchema)
.mutation(async ({ input, ctx }) => {
const { name, teamCount, memberCount, envelopeItemCount, flags } = input;
const {
name,
teamCount,
memberCount,
envelopeItemCount,
recipientCount,
flags,
documentRateLimits,
documentQuota,
emailRateLimits,
emailQuota,
apiRateLimits,
apiQuota,
emailTransportId,
} = input;
ctx.logger.info({
input,
@@ -21,8 +35,16 @@ export const createSubscriptionClaimRoute = adminProcedure
name,
teamCount,
envelopeItemCount,
recipientCount,
memberCount,
flags,
documentRateLimits,
documentQuota,
emailRateLimits,
emailQuota,
apiRateLimits,
apiQuota,
emailTransportId,
},
});
});
@@ -1,4 +1,4 @@
import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
import { z } from 'zod';
export const ZCreateSubscriptionClaimRequestSchema = z.object({
@@ -6,7 +6,19 @@ export const ZCreateSubscriptionClaimRequestSchema = z.object({
teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0),
envelopeItemCount: z.number().int().min(1),
recipientCount: z.number().int().min(0),
flags: ZClaimFlagsSchema,
documentRateLimits: ZRateLimitArraySchema,
documentQuota: z.number().int().min(0).nullable(),
emailRateLimits: ZRateLimitArraySchema,
emailQuota: z.number().int().min(0).nullable(),
apiRateLimits: ZRateLimitArraySchema,
apiQuota: z.number().int().min(0).nullable(),
emailTransportId: z.string().nullable(),
});
export const ZCreateSubscriptionClaimResponseSchema = z.void();
@@ -0,0 +1,32 @@
import { encryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../../trpc';
import {
ZCreateEmailTransportRequestSchema,
ZCreateEmailTransportResponseSchema,
} from './create-email-transport.types';
export const createEmailTransportRoute = adminProcedure
.input(ZCreateEmailTransportRequestSchema)
.output(ZCreateEmailTransportResponseSchema)
.mutation(async ({ input }) => {
const { name, fromName, fromAddress, config } = input;
const transport = await prisma.emailTransport.create({
data: {
id: generateDatabaseId('email_transport'),
name,
type: config.type,
fromName,
fromAddress,
config: encryptEmailTransportConfig(config),
},
select: { id: true },
});
return {
id: transport.id,
};
});
@@ -0,0 +1,15 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
config: ZEmailTransportConfigSchema,
});
export const ZCreateEmailTransportResponseSchema = z.object({
id: z.string(),
});
export type TCreateEmailTransportRequest = z.infer<typeof ZCreateEmailTransportRequestSchema>;
@@ -0,0 +1,24 @@
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../../trpc';
import {
ZDeleteEmailTransportRequestSchema,
ZDeleteEmailTransportResponseSchema,
} from './delete-email-transport.types';
export const deleteEmailTransportRoute = adminProcedure
.input(ZDeleteEmailTransportRequestSchema)
.output(ZDeleteEmailTransportResponseSchema)
.mutation(async ({ input, ctx }) => {
ctx.logger.info({
input: {
id: input.id,
},
});
await prisma.emailTransport.delete({
where: {
id: input.id,
},
});
});
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const ZDeleteEmailTransportRequestSchema = z.object({
id: z.string(),
});
export const ZDeleteEmailTransportResponseSchema = z.void();
@@ -0,0 +1,65 @@
import {
decryptEmailTransportConfig,
toPublicEmailTransportConfig,
} from '@documenso/lib/server-only/email/email-transport-config';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@prisma/client';
import { adminProcedure } from '../../trpc';
import { ZFindEmailTransportsRequestSchema, ZFindEmailTransportsResponseSchema } from './find-email-transports.types';
export const findEmailTransportsRoute = adminProcedure
.input(ZFindEmailTransportsRequestSchema)
.output(ZFindEmailTransportsResponseSchema)
.query(async ({ input }) => {
const { query, page = 1, perPage = 20 } = input;
const where: Prisma.EmailTransportWhereInput = query
? {
OR: [
{ name: { contains: query, mode: Prisma.QueryMode.insensitive } },
{ fromAddress: { contains: query, mode: Prisma.QueryMode.insensitive } },
],
}
: {};
const [transports, count] = await Promise.all([
prisma.emailTransport.findMany({
where,
skip: (page - 1) * perPage,
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { subscriptionClaims: true, organisationClaims: true },
},
},
}),
prisma.emailTransport.count({ where }),
]);
// Replace the encrypted `config` blob with the non-secret connection
// settings so the encrypted value (and secrets) never leave the server.
const data = transports.map(({ config, ...transport }) => {
let publicConfig: ReturnType<typeof toPublicEmailTransportConfig> | null = null;
try {
publicConfig = toPublicEmailTransportConfig(decryptEmailTransportConfig(config));
} catch {
publicConfig = null;
}
return {
...transport,
config: publicConfig,
};
});
return {
data,
count,
currentPage: page,
perPage,
totalPages: Math.ceil(count / perPage),
};
});
@@ -0,0 +1,31 @@
import { ZEmailTransportPublicConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import EmailTransportSchema from '@documenso/prisma/generated/zod/modelSchema/EmailTransportSchema';
import { z } from 'zod';
export const ZFindEmailTransportsRequestSchema = ZFindSearchParamsSchema;
export const ZFindEmailTransportsResponseSchema = ZFindResultResponse.extend({
data: EmailTransportSchema.pick({
id: true,
name: true,
type: true,
fromName: true,
fromAddress: true,
createdAt: true,
updatedAt: true,
})
.extend({
_count: z.object({
subscriptionClaims: z.number(),
organisationClaims: z.number(),
}),
// Non-secret connection settings, so the edit form can pre-fill them.
// Null when the stored config can't be decrypted/parsed.
config: ZEmailTransportPublicConfigSchema.nullable(),
})
.array(),
});
export type TFindEmailTransportsRequest = z.infer<typeof ZFindEmailTransportsRequestSchema>;
export type TFindEmailTransportsResponse = z.infer<typeof ZFindEmailTransportsResponseSchema>;
@@ -0,0 +1,49 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { resolveEmailTransport } from '@documenso/lib/server-only/email/resolve-email-transport';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../../trpc';
import {
ZSendTestEmailTransportRequestSchema,
ZSendTestEmailTransportResponseSchema,
} from './send-test-email-transport.types';
export const sendTestEmailTransportRoute = adminProcedure
.input(ZSendTestEmailTransportRequestSchema)
.output(ZSendTestEmailTransportResponseSchema)
.mutation(async ({ input, ctx }) => {
ctx.logger.info({
input: {
id: input.id,
},
});
const transport = await prisma.emailTransport.findUnique({
where: {
id: input.id,
},
});
if (!transport) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Email transport not found' });
}
const resolved = await resolveEmailTransport(input.id);
if (!resolved) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to build transport from stored configuration.',
});
}
try {
await resolved.transporter.sendMail({
to: input.to,
from: { name: transport.fromName, address: transport.fromAddress },
subject: 'Documenso email transport test',
text: `This is a test email sent through the "${transport.name}" email transport.`,
});
} catch (err) {
throw AppError.parseError(err);
}
});
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZSendTestEmailTransportRequestSchema = z.object({
id: z.string(),
to: z.string().email(),
});
export const ZSendTestEmailTransportResponseSchema = z.void();
export type TSendTestEmailTransportResponse = z.infer<typeof ZSendTestEmailTransportResponseSchema>;
@@ -0,0 +1,57 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
decryptEmailTransportConfig,
EMAIL_TRANSPORT_SECRET_KEYS,
encryptEmailTransportConfig,
ZEmailTransportConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../../trpc';
import {
ZUpdateEmailTransportRequestSchema,
ZUpdateEmailTransportResponseSchema,
} from './update-email-transport.types';
export const updateEmailTransportRoute = adminProcedure
.input(ZUpdateEmailTransportRequestSchema)
.output(ZUpdateEmailTransportResponseSchema)
.mutation(async ({ input }) => {
const { id, data } = input;
const existing = await prisma.emailTransport.findUnique({
where: { id },
});
if (!existing) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Email transport not found' });
}
const existingConfig = decryptEmailTransportConfig(existing.config);
// Start from the incoming config; backfill empty secret fields from the existing
// config (only when the type is unchanged).
const merged: Record<string, unknown> = { ...data.config };
if (existingConfig.type === data.config.type) {
for (const key of EMAIL_TRANSPORT_SECRET_KEYS) {
const incoming = (data.config as Record<string, unknown>)[key];
if (incoming === undefined || incoming === '') {
merged[key] = (existingConfig as Record<string, unknown>)[key];
}
}
}
const config = ZEmailTransportConfigSchema.parse(merged);
await prisma.emailTransport.update({
where: { id },
data: {
name: data.name,
type: config.type,
fromName: data.fromName,
fromAddress: data.fromAddress,
config: encryptEmailTransportConfig(config),
},
});
});
@@ -0,0 +1,33 @@
import {
ZMailChannelsConfigSchema,
ZResendConfigSchema,
ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config';
import { z } from 'zod';
// Reuses the canonical transport config schemas, but relaxes the secret field so
// a blank/omitted value means "keep existing". Note: `.partial()` only makes the
// key optional — it keeps the `.min(1)` validator, so an empty string would be
// rejected. We override the secret field with a plain optional string instead.
// (SMTP_AUTH's `password` is already optional in the source schema.)
const ZUpdateConfigSchema = z.discriminatedUnion('type', [
ZSmtpAuthConfigSchema,
ZSmtpApiConfigSchema.extend({ apiKey: z.string().optional() }),
ZResendConfigSchema.extend({ apiKey: z.string().optional() }),
ZMailChannelsConfigSchema.extend({ apiKey: z.string().optional() }),
]);
export const ZUpdateEmailTransportRequestSchema = z.object({
id: z.string(),
data: z.object({
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
config: ZUpdateConfigSchema,
}),
});
export const ZUpdateEmailTransportResponseSchema = z.void();
export type TUpdateEmailTransportRequest = z.infer<typeof ZUpdateEmailTransportRequestSchema>;
@@ -0,0 +1,148 @@
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { match } from 'ts-pattern';
import { adminProcedure } from '../trpc';
import {
ZFindOrganisationStatsRequestSchema,
ZFindOrganisationStatsResponseSchema,
} from './find-organisation-stats.types';
export const findOrganisationStatsRoute = adminProcedure
.input(ZFindOrganisationStatsRequestSchema)
.output(ZFindOrganisationStatsResponseSchema)
.query(async ({ input }) => {
const { query, period, claimId, page, perPage, orderByColumn, orderByDirection } = input;
return await findOrganisationStats({
query,
period,
claimId,
page,
perPage,
orderByColumn,
orderByDirection,
});
});
type FindOrganisationStatsOptions = {
query?: string;
period?: string;
claimId?: string;
page?: number;
perPage?: number;
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
orderByDirection?: 'asc' | 'desc';
};
export const findOrganisationStats = async ({
query,
period,
claimId,
page = 1,
perPage = 10,
orderByColumn,
orderByDirection = 'desc',
}: FindOrganisationStatsOptions) => {
const offset = Math.max(page - 1, 0) * perPage;
// Stats are always scoped to a single month. Default to the current month when none is given.
const resolvedPeriod = period ?? currentMonthlyPeriod();
const totalCountExpression = sql<number>`(
"OrganisationMonthlyStat"."documentCount"
+ "OrganisationMonthlyStat"."emailCount"
+ "OrganisationMonthlyStat"."apiCount"
)`;
let baseQuery = kyselyPrisma.$kysely
.selectFrom('OrganisationMonthlyStat')
.innerJoin('Organisation', 'Organisation.id', 'OrganisationMonthlyStat.organisationId')
.leftJoin('OrganisationClaim', 'OrganisationClaim.id', 'Organisation.organisationClaimId')
.where('OrganisationMonthlyStat.period', '=', resolvedPeriod);
if (query) {
// Organisation IDs are prefixed with `org_`. When the query uses that prefix it is
// unambiguously an ID (or URL) lookup, so use indexed equality matches instead of
// scanning every column with `ILIKE`.
if (query.startsWith('org_')) {
baseQuery = baseQuery.where((eb) =>
eb.or([eb('Organisation.id', '=', query), eb('Organisation.url', '=', query)]),
);
} else {
baseQuery = baseQuery.where((eb) =>
eb.or([eb('Organisation.name', 'ilike', `%${query}%`), eb('Organisation.url', 'ilike', `%${query}%`)]),
);
}
}
if (claimId) {
baseQuery = baseQuery.where('OrganisationClaim.originalSubscriptionClaimId', '=', claimId);
}
const dataQuery = baseQuery
.select((eb) => [
'OrganisationMonthlyStat.id as id',
'OrganisationMonthlyStat.organisationId as organisationId',
'Organisation.name as organisationName',
'OrganisationClaim.originalSubscriptionClaimId as originalClaimId',
'OrganisationMonthlyStat.period as period',
'OrganisationMonthlyStat.documentCount as documentCount',
'OrganisationMonthlyStat.emailCount as emailCount',
'OrganisationMonthlyStat.apiCount as apiCount',
'OrganisationMonthlyStat.emailReports as emailReports',
'OrganisationClaim.documentQuota as documentQuota',
'OrganisationClaim.emailQuota as emailQuota',
'OrganisationClaim.apiQuota as apiQuota',
totalCountExpression.as('totalCount'),
eb.fn.countAll().over().as('totalRows'),
])
.$call((qb) =>
match(orderByColumn)
.with('documentCount', () => qb.orderBy('OrganisationMonthlyStat.documentCount', orderByDirection))
.with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection))
.with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection))
.with('emailReports', () => qb.orderBy('OrganisationMonthlyStat.emailReports', orderByDirection))
.with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection))
.with(undefined, () =>
// Default ordering mirrors the desired SQL: email, api, document descending.
qb
.orderBy('OrganisationMonthlyStat.emailCount', 'desc')
.orderBy('OrganisationMonthlyStat.apiCount', 'desc')
.orderBy('OrganisationMonthlyStat.documentCount', 'desc'),
)
.exhaustive(),
)
.orderBy('OrganisationMonthlyStat.id', 'asc')
.limit(perPage)
.offset(offset);
const rows = await dataQuery.execute();
const count = rows.length > 0 ? Number(rows[0].totalRows) : 0;
const data = rows.map((row) => ({
id: row.id,
organisationId: row.organisationId,
organisationName: row.organisationName,
originalClaimId: row.originalClaimId,
period: row.period,
documentCount: Number(row.documentCount),
emailCount: Number(row.emailCount),
apiCount: Number(row.apiCount),
emailReports: Number(row.emailReports),
documentQuota: row.documentQuota === null ? null : Number(row.documentQuota),
emailQuota: row.emailQuota === null ? null : Number(row.emailQuota),
apiQuota: row.apiQuota === null ? null : Number(row.apiQuota),
totalCount: Number(row.totalCount),
}));
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};
@@ -0,0 +1,39 @@
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { z } from 'zod';
export const ZFindOrganisationStatsRequestSchema = ZFindSearchParamsSchema.extend({
period: z
.string()
.regex(/^\d{4}-\d{2}$/, 'Period must be in YYYY-MM format.')
.describe('Filter stats by UTC calendar month in `YYYY-MM` form, e.g. "2026-05".')
.optional(),
claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(),
orderByColumn: z
.enum(['documentCount', 'emailCount', 'apiCount', 'emailReports', 'totalCount'])
.describe('The column to sort by.')
.optional(),
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
});
export const ZFindOrganisationStatsResponseSchema = ZFindResultResponse.extend({
data: z
.object({
id: z.string(),
organisationId: z.string(),
organisationName: z.string(),
originalClaimId: z.string().nullable(),
period: z.string(),
documentCount: z.number(),
emailCount: z.number(),
apiCount: z.number(),
emailReports: z.number(),
documentQuota: z.number().nullable(),
emailQuota: z.number().nullable(),
apiQuota: z.number().nullable(),
totalCount: z.number(),
})
.array(),
});
export type TFindOrganisationStatsRequest = z.infer<typeof ZFindOrganisationStatsRequestSchema>;
export type TFindOrganisationStatsResponse = z.infer<typeof ZFindOrganisationStatsResponseSchema>;
@@ -13,8 +13,16 @@ export const ZFindSubscriptionClaimsResponseSchema = ZFindResultResponse.extend(
teamCount: true,
memberCount: true,
envelopeItemCount: true,
recipientCount: true,
locked: true,
flags: true,
documentRateLimits: true,
documentQuota: true,
emailRateLimits: true,
emailQuota: true,
apiRateLimits: true,
apiQuota: true,
emailTransportId: true,
}).array(),
});
@@ -37,6 +37,11 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp
organisationClaim: true,
organisationGlobalSettings: true,
teams: true,
monthlyStats: {
orderBy: {
period: 'desc',
},
},
members: {
include: {
organisationGroupMembers: {
@@ -63,5 +68,7 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp
});
}
return organisation;
return {
...organisation,
};
};
@@ -4,6 +4,7 @@ import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/mo
import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema';
import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationMonthlyStatSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMonthlyStatSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
@@ -46,6 +47,15 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
}).array(),
subscription: SubscriptionSchema.nullable(),
organisationClaim: OrganisationClaimSchema,
monthlyStats: z.array(
OrganisationMonthlyStatSchema.pick({
period: true,
documentCount: true,
emailCount: true,
apiCount: true,
emailReports: true,
}),
),
});
export type TGetAdminOrganisationResponse = z.infer<typeof ZGetAdminOrganisationResponseSchema>;
@@ -0,0 +1,32 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import { match } from 'ts-pattern';
import { adminProcedure } from '../trpc';
import {
ZResetOrganisationMonthlyStatRequestSchema,
ZResetOrganisationMonthlyStatResponseSchema,
} from './reset-organisation-monthly-stat.types';
export const resetOrganisationMonthlyStatRoute = adminProcedure
.input(ZResetOrganisationMonthlyStatRequestSchema)
.output(ZResetOrganisationMonthlyStatResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, counter } = input;
const period = currentMonthlyPeriod();
ctx.logger.info({ organisationId, counter, period });
const data: Prisma.OrganisationMonthlyStatUpdateInput = match(counter)
.with('document', () => ({ documentCount: 0 }))
.with('email', () => ({ emailCount: 0 }))
.with('api', () => ({ apiCount: 0 }))
.exhaustive();
await prisma.organisationMonthlyStat.update({
where: { organisationId_period: { organisationId, period } },
data,
});
});
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZResetOrganisationMonthlyStatRequestSchema = z.object({
organisationId: z.string(),
counter: z.enum(['document', 'email', 'api']),
});
export const ZResetOrganisationMonthlyStatResponseSchema = z.void();
export type TResetOrganisationMonthlyStatRequest = z.infer<typeof ZResetOrganisationMonthlyStatRequestSchema>;
+23 -1
View File
@@ -11,12 +11,18 @@ import { deleteAdminTeamMemberRoute } from './delete-team-member';
import { deleteUserRoute } from './delete-user';
import { disableUserRoute } from './disable-user';
import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs';
import { createEmailTransportRoute } from './email-transport/create-email-transport';
import { deleteEmailTransportRoute } from './email-transport/delete-email-transport';
import { findEmailTransportsRoute } from './email-transport/find-email-transports';
import { sendTestEmailTransportRoute } from './email-transport/send-test-email-transport';
import { updateEmailTransportRoute } from './email-transport/update-email-transport';
import { enableUserRoute } from './enable-user';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
import { findDocumentJobsRoute } from './find-document-jobs';
import { findDocumentsRoute } from './find-documents';
import { findEmailDomainsRoute } from './find-email-domains';
import { findOrganisationStatsRoute } from './find-organisation-stats';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { findUnsealedDocumentsRoute } from './find-unsealed-documents';
import { findUserTeamsRoute } from './find-user-teams';
@@ -27,9 +33,11 @@ import { getUserRoute } from './get-user';
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
import { reregisterEmailDomainRoute } from './reregister-email-domain';
import { resealDocumentRoute } from './reseal-document';
import { resetOrganisationMonthlyStatRoute } from './reset-organisation-monthly-stat';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { resyncLicenseRoute } from './resync-license';
import { swapOrganisationSubscriptionRoute } from './swap-organisation-subscription';
import { syncOrganisationSubscriptionRoute } from './sync-organisation-subscription';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
@@ -44,7 +52,14 @@ export const adminRouter = router({
create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute,
delete: deleteOrganisationRoute,
swapSubscription: swapOrganisationSubscriptionRoute,
subscription: {
swap: swapOrganisationSubscriptionRoute,
sync: syncOrganisationSubscriptionRoute,
},
stats: {
find: findOrganisationStatsRoute,
reset: resetOrganisationMonthlyStatRoute,
},
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
@@ -90,6 +105,13 @@ export const adminRouter = router({
get: getEmailDomainRoute,
reregister: reregisterEmailDomainRoute,
},
emailTransport: {
find: findEmailTransportsRoute,
create: createEmailTransportRoute,
update: updateEmailTransportRoute,
delete: deleteEmailTransportRoute,
sendTest: sendTestEmailTransportRoute,
},
team: {
get: getAdminTeamRoute,
},
@@ -1,6 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@prisma/client';
@@ -85,6 +86,8 @@ export const swapOrganisationSubscriptionRoute = adminProcedure
const customerId = sourceOrg.customerId ?? sourceOrg.subscription.customerId;
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
await prisma.$transaction(async (tx) => {
// Delete stale INACTIVE subscription on target if present.
if (targetOrg.subscription) {
@@ -120,6 +123,7 @@ export const swapOrganisationSubscriptionRoute = adminProcedure
teamCount: sourceOrg.organisationClaim.teamCount,
memberCount: sourceOrg.organisationClaim.memberCount,
envelopeItemCount: sourceOrg.organisationClaim.envelopeItemCount,
recipientCount: sourceOrg.organisationClaim.recipientCount,
flags: sourceOrg.organisationClaim.flags,
},
});
@@ -131,7 +135,7 @@ export const swapOrganisationSubscriptionRoute = adminProcedure
where: { id: sourceOrg.organisationClaim.id },
data: {
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]),
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
},
});
}
@@ -0,0 +1,44 @@
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZSyncOrganisationSubscriptionRequestSchema,
ZSyncOrganisationSubscriptionResponseSchema,
} from './sync-organisation-subscription.types';
export const syncOrganisationSubscriptionRoute = adminProcedure
.input(ZSyncOrganisationSubscriptionRequestSchema)
.output(ZSyncOrganisationSubscriptionResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, syncClaims } = input;
ctx.logger.info({
input: {
organisationId,
syncClaims,
},
});
const organisation = await prisma.organisation.findUnique({
where: { id: organisationId },
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!organisation.customerId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation has no Stripe customer to sync from',
});
}
await syncStripeCustomerSubscription({
customerId: organisation.customerId,
bypassClaimUpdate: !syncClaims,
});
});
@@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZSyncOrganisationSubscriptionRequestSchema = z.object({
organisationId: z.string(),
syncClaims: z.boolean(),
});
export const ZSyncOrganisationSubscriptionResponseSchema = z.void();
export type TSyncOrganisationSubscriptionRequest = z.infer<typeof ZSyncOrganisationSubscriptionRequestSchema>;
export type TSyncOrganisationSubscriptionResponse = z.infer<typeof ZSyncOrganisationSubscriptionResponseSchema>;
@@ -13,7 +13,15 @@ export const ZUpdateAdminOrganisationRequestSchema = z.object({
teamCount: true,
memberCount: true,
envelopeItemCount: true,
recipientCount: true,
flags: true,
documentRateLimits: true,
documentQuota: true,
emailRateLimits: true,
emailQuota: true,
apiRateLimits: true,
apiQuota: true,
emailTransportId: true,
}).optional(),
customerId: z.string().optional(),
originalSubscriptionClaimId: z.string().optional(),
@@ -13,7 +13,7 @@ export const updateSubscriptionClaimRoute = adminProcedure
.input(ZUpdateSubscriptionClaimRequestSchema)
.output(ZUpdateSubscriptionClaimResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, data } = input;
const { id, data, backportEmailTransport } = input;
ctx.logger.info({
input,
@@ -36,6 +36,13 @@ export const updateSubscriptionClaimRoute = adminProcedure
data,
});
if (backportEmailTransport) {
await prisma.organisationClaim.updateMany({
where: { originalSubscriptionClaimId: id },
data: { emailTransportId: data.emailTransportId ?? null },
});
}
if (Object.keys(newlyEnabledFlags).length > 0) {
await jobsClient.triggerJob({
name: 'internal.backport-subscription-claims',
@@ -5,6 +5,9 @@ import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-cla
export const ZUpdateSubscriptionClaimRequestSchema = z.object({
id: z.string(),
data: ZCreateSubscriptionClaimRequestSchema,
// When enabled, the claim's email transport is propagated to all organisations
// currently using this claim.
backportEmailTransport: z.boolean().default(false),
});
export const ZUpdateSubscriptionClaimResponseSchema = z.void();
@@ -15,18 +15,18 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
.output(ZDownloadDocumentAuditLogsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
const { envelopeId } = input;
ctx.logger.info({
input: {
documentId,
envelopeId,
},
});
const envelope = await getEnvelopeById({
id: {
type: 'documentId',
id: documentId,
type: 'envelopeId',
id: envelopeId,
},
type: EnvelopeType.DOCUMENT,
userId: ctx.user.id,
@@ -55,10 +55,8 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
const result = await certificatePdf.save();
const base64 = Buffer.from(result).toString('base64');
return {
data: base64,
data: Buffer.from(result).toString('base64'),
envelopeTitle: envelope.title,
};
});
@@ -1,7 +1,7 @@
import { z } from 'zod';
export const ZDownloadDocumentAuditLogsRequestSchema = z.object({
documentId: z.number(),
envelopeId: z.string(),
});
export const ZDownloadDocumentAuditLogsResponseSchema = z.object({
@@ -3,7 +3,7 @@ import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelop
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import type { DocumentData } from '@prisma/client';
import { DocumentDataType, EnvelopeType } from '@prisma/client';
import { DocumentDataType, DocumentStatus, EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
@@ -58,7 +58,12 @@ export const downloadDocumentBetaRoute = authenticatedProcedure
});
}
if (version === 'signed' && !isDocumentCompleted(envelope.status)) {
// A cancelled document was never sealed, so its data is the unsigned original.
// Treat it as not-completed here so a "signed" version is never served for it.
// REJECTED and COMPLETED keep their prior behavior.
const hasSignedArtifact = isDocumentCompleted(envelope.status) && envelope.status !== DocumentStatus.CANCELLED;
if (version === 'signed' && !hasSignedArtifact) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not completed yet.',
});
@@ -4,7 +4,7 @@ import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-e
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
@@ -17,18 +17,18 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
.output(ZDownloadDocumentCertificateResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
const { envelopeId } = input;
ctx.logger.info({
input: {
documentId,
envelopeId,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
type: 'envelopeId',
id: envelopeId,
},
type: EnvelopeType.DOCUMENT,
userId: ctx.user.id,
@@ -60,7 +60,9 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
});
}
if (!isDocumentCompleted(envelope.status)) {
// A cancelled document was never sealed/completed, so a signing certificate
// must not be generated for it. REJECTED and COMPLETED keep their prior behavior.
if (!isDocumentCompleted(envelope.status) || envelope.status === DocumentStatus.CANCELLED) {
throw new AppError('DOCUMENT_NOT_COMPLETE');
}
@@ -79,10 +81,8 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
const result = await certificatePdf.save();
const base64 = Buffer.from(result).toString('base64');
return {
data: base64,
data: Buffer.from(result).toString('base64'),
envelopeTitle: envelope.title,
};
});
@@ -1,7 +1,7 @@
import { z } from 'zod';
export const ZDownloadDocumentCertificateRequestSchema = z.object({
documentId: z.number(),
envelopeId: z.string(),
});
export const ZDownloadDocumentCertificateResponseSchema = z.object({
@@ -19,6 +19,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
[ExtendedDocumentStatus.PENDING]: z.number(),
[ExtendedDocumentStatus.COMPLETED]: z.number(),
[ExtendedDocumentStatus.REJECTED]: z.number(),
[ExtendedDocumentStatus.CANCELLED]: z.number(),
[ExtendedDocumentStatus.INBOX]: z.number(),
[ExtendedDocumentStatus.ALL]: z.number(),
}),
@@ -22,7 +22,7 @@ export const getInboxCountRoute = authenticatedProcedure
envelope: {
type: EnvelopeType.DOCUMENT,
status: {
not: DocumentStatus.DRAFT,
notIn: [DocumentStatus.DRAFT, DocumentStatus.REJECTED],
},
deletedAt: null,
},
@@ -0,0 +1,43 @@
import { executeTspSign } from '@documenso/ee/server-only/signing/csc/execute-tsp-sign';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { procedure } from '../trpc';
import { ZCscSignEnvelopeRequestSchema, ZCscSignEnvelopeResponseSchema } from './csc-sign-envelope.types';
/**
* Internal mutation that drives the CSC TSP sign-time pipeline.
*
* `executeTspSign` does the heavy lifting (capture → batched signHash →
* embed → tx). This route wraps it in a 15s `Promise.race` so an unresponsive
* TSP surfaces as `CSC_TSP_TIMEOUT` instead of hanging the request. The
* idle-timer is a soft cap on TSP round-trip latency; the underlying tx
* keeps running on the server until it completes or errors.
*/
const SIGN_TIMEOUT_MS = 15_000;
export const cscSignEnvelopeRoute = procedure
.input(ZCscSignEnvelopeRequestSchema)
.output(ZCscSignEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const result = await Promise.race([
executeTspSign({
sessionId: input.sessionId,
recipientToken: input.recipientToken,
requestMetadata: ctx.metadata.requestMetadata,
}),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new AppError(AppErrorCode.CSC_TSP_TIMEOUT, {
message: 'CSC TSP did not respond within 15s.',
}),
),
SIGN_TIMEOUT_MS,
),
),
]);
return result;
});
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const ZCscSignEnvelopeRequestSchema = z.object({
recipientToken: z.string().min(1),
sessionId: z.string().min(1),
});
export const ZCscSignEnvelopeResponseSchema = z.object({
outcome: z.enum(['signed', 'already_signed']),
});
export type TCscSignEnvelopeRequest = z.infer<typeof ZCscSignEnvelopeRequestSchema>;
export type TCscSignEnvelopeResponse = z.infer<typeof ZCscSignEnvelopeResponseSchema>;
@@ -1,7 +1,14 @@
import { getInternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { getSubscription } from '@documenso/ee/server-only/stripe/get-subscription';
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { Stripe } from '@documenso/lib/server-only/stripe';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import type { Logger } from 'pino';
import { authenticatedProcedure } from '../trpc';
import { ZGetSubscriptionRequestSchema } from './get-subscription.types';
@@ -26,9 +33,17 @@ export const getSubscriptionRoute = authenticatedProcedure
}
const [subscription, plans] = await Promise.all([
// If the subscription is not found or there's an error, we return null to
// avoid failing the entire request.
getSubscription({
organisationId,
userId,
}).catch(async (e) => {
ctx.logger.error(`Failed to get subscription for organisation ${organisationId}`, e);
await reconcileMissingStripeSubscription({ logger: ctx.logger, organisationId, userId, error: e });
return null;
}),
getInternalClaimPlans(),
]);
@@ -38,3 +53,51 @@ export const getSubscriptionRoute = authenticatedProcedure
plans,
};
});
type ReconcileMissingStripeSubscriptionOptions = {
logger: Logger;
organisationId: string;
userId: number;
error: unknown;
};
/**
* When the Stripe subscription no longer exists (e.g. deleted by Stripe's
* test-mode retention policy, or removed manually), fire-and-forget a reconcile
* so the stale local subscription row and any billing banner converge on the
* next load. Reconcile failures must never break the read path that calls this.
*/
const reconcileMissingStripeSubscription = async ({
logger,
organisationId,
userId,
error,
}: ReconcileMissingStripeSubscriptionOptions) => {
if (!(error instanceof Stripe.errors.StripeInvalidRequestError) || error.code !== 'resource_missing') {
return;
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
select: {
customerId: true,
},
});
if (!organisation?.customerId) {
return;
}
void syncStripeCustomerSubscription({
customerId: organisation.customerId,
}).catch((syncError) => {
logger.error(
`Failed to reconcile subscription after resource_missing for organisation ${organisationId}`,
syncError,
);
});
};
@@ -2,6 +2,7 @@ import { router } from '../trpc';
import { createOrganisationEmailRoute } from './create-organisation-email';
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
import { createSubscriptionRoute } from './create-subscription';
import { cscSignEnvelopeRoute } from './csc-sign-envelope';
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
@@ -14,6 +15,7 @@ import { getPlansRoute } from './get-plans';
import { getSubscriptionRoute } from './get-subscription';
import { linkOrganisationAccountRoute } from './link-organisation-account';
import { manageSubscriptionRoute } from './manage-subscription';
import { syncSubscriptionRoute } from './sync-subscription';
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
import { updateOrganisationEmailRoute } from './update-organisation-email';
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
@@ -48,9 +50,13 @@ export const enterpriseRouter = router({
get: getSubscriptionRoute,
create: createSubscriptionRoute,
manage: manageSubscriptionRoute,
sync: syncSubscriptionRoute,
},
invoices: {
get: getInvoicesRoute,
},
},
csc: {
signEnvelope: cscSignEnvelopeRoute,
},
});
@@ -0,0 +1,70 @@
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { syncSubscriptionRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import { ZSyncSubscriptionRequestSchema, ZSyncSubscriptionResponseSchema } from './sync-subscription.types';
export const syncSubscriptionRoute = authenticatedProcedure
.input(ZSyncSubscriptionRequestSchema)
.output(ZSyncSubscriptionResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId } = input;
ctx.logger.info({
input: {
organisationId,
},
});
const userId = ctx.user.id;
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const rateLimitResult = await syncSubscriptionRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: `${userId}`,
});
assertRateLimit(rateLimitResult);
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_BILLING,
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
if (!organisation.customerId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation has no billing customer',
});
}
await syncStripeCustomerSubscription({
customerId: organisation.customerId,
}).catch((error) => {
ctx.logger.error({
msg: 'Failed to sync the subscription from Stripe',
error,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to sync the subscription from Stripe',
});
});
});
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZSyncSubscriptionRequestSchema = z.object({
organisationId: z.string().describe('The organisation to sync the subscription for'),
});
export const ZSyncSubscriptionResponseSchema = z.void();
export type TSyncSubscriptionRequest = z.infer<typeof ZSyncSubscriptionRequestSchema>;
export type TSyncSubscriptionResponse = z.infer<typeof ZSyncSubscriptionResponseSchema>;
@@ -1,3 +1,4 @@
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { z } from 'zod';
import type { TrpcRouteMeta } from '../../trpc';
@@ -16,7 +17,7 @@ export const ZCreateAttachmentRequestSchema = z.object({
envelopeId: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
}),
});
@@ -1,3 +1,4 @@
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { z } from 'zod';
import { ZSuccessResponseSchema } from '../../schema';
@@ -17,7 +18,7 @@ export const ZUpdateAttachmentRequestSchema = z.object({
id: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
}),
});
@@ -0,0 +1,95 @@
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import pMap from 'p-map';
import { authenticatedProcedure } from '../trpc';
import { ZBulkCancelEnvelopesRequestSchema, ZBulkCancelEnvelopesResponseSchema } from './bulk-cancel-envelopes.types';
export const bulkCancelEnvelopesRoute = authenticatedProcedure
.input(ZBulkCancelEnvelopesRequestSchema)
.output(ZBulkCancelEnvelopesResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeIds, reason } = input;
ctx.logger.info({
input: {
envelopeIds,
},
});
// Cancelling only applies to documents. Templates are filtered out here so
// selecting a template never counts towards a cancellation.
const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({
ids: {
type: 'envelopeId',
ids: envelopeIds,
},
userId: user.id,
teamId,
type: EnvelopeType.DOCUMENT,
});
const envelopes = await prisma.envelope.findMany({
where: envelopeWhereInput,
select: {
id: true,
},
});
const results = await pMap(
envelopes,
async (envelope) => {
const { id: envelopeId } = envelope;
try {
await cancelDocument({
id: {
type: 'envelopeId',
id: envelopeId,
},
userId: user.id,
teamId,
reason,
requestMetadata: ctx.metadata,
});
return {
success: true,
envelopeId,
};
} catch (err) {
ctx.logger.warn(
{
envelopeId,
error: err,
},
'Failed to cancel envelope during bulk cancel',
);
return {
success: false,
envelopeId,
};
}
},
{
concurrency: 10,
stopOnError: false,
},
);
const cancelledCount = results.filter((r) => r.success).length;
const failedIds = results.filter((r) => !r.success).map((r) => r.envelopeId);
// Include envelope IDs that were not attempted (unauthorized/not found/not a document).
const attemptedIds = new Set(envelopes.map((e) => e.id));
const unattemptedIds = envelopeIds.filter((id) => !attemptedIds.has(id));
return {
cancelledCount,
failedIds: [...failedIds, ...unattemptedIds],
};
});
@@ -0,0 +1,21 @@
import { z } from 'zod';
// Keeping this as a private API for a little while until we're sure it's stable
// and the request/response schemas are finalized.
export const ZBulkCancelEnvelopesRequestSchema = z.object({
envelopeIds: z
.array(z.string())
.min(1)
.max(100)
.describe('The IDs of the envelopes to cancel. The maximum number of envelopes you can cancel at once is 100.'),
reason: z.string().optional(),
});
export const ZBulkCancelEnvelopesResponseSchema = z.object({
cancelledCount: z.number(),
failedIds: z.array(z.string()),
});
export type TBulkCancelEnvelopesRequest = z.infer<typeof ZBulkCancelEnvelopesRequestSchema>;
export type TBulkCancelEnvelopesResponse = z.infer<typeof ZBulkCancelEnvelopesResponseSchema>;
@@ -0,0 +1,37 @@
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
import { ZGenericSuccessResponse } from '../schema';
import { authenticatedProcedure } from '../trpc';
import {
cancelEnvelopeMeta,
ZCancelEnvelopeRequestSchema,
ZCancelEnvelopeResponseSchema,
} from './cancel-envelope.types';
export const cancelEnvelopeRoute = authenticatedProcedure
.meta(cancelEnvelopeMeta)
.input(ZCancelEnvelopeRequestSchema)
.output(ZCancelEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { envelopeId, reason } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
await cancelDocument({
id: {
type: 'envelopeId',
id: envelopeId,
},
userId: ctx.user.id,
teamId,
reason,
requestMetadata: ctx.metadata,
});
return ZGenericSuccessResponse;
});
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { ZSuccessResponseSchema } from '../schema';
import type { TrpcRouteMeta } from '../trpc';
export const cancelEnvelopeMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/cancel',
summary: 'Cancel envelope',
tags: ['Envelope'],
},
};
export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
reason: z.string().optional(),
});
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
export type TCancelEnvelopeRequest = z.infer<typeof ZCancelEnvelopeRequestSchema>;
export type TCancelEnvelopeResponse = z.infer<typeof ZCancelEnvelopeResponseSchema>;
@@ -0,0 +1,23 @@
import { authenticatedProcedure } from '../trpc';
import {
downloadEnvelopeAuditLogPdfMeta,
ZDownloadEnvelopeAuditLogPdfRequestSchema,
ZDownloadEnvelopeAuditLogPdfResponseSchema,
} from './download-envelope-audit-log-pdf.types';
export const downloadEnvelopeAuditLogPdfRoute = authenticatedProcedure
.meta(downloadEnvelopeAuditLogPdfMeta)
.input(ZDownloadEnvelopeAuditLogPdfRequestSchema)
.output(ZDownloadEnvelopeAuditLogPdfResponseSchema)
.query(({ input, ctx }) => {
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
throw new Error('NOT_IMPLEMENTED');
});
@@ -0,0 +1,25 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../trpc';
export const downloadEnvelopeAuditLogPdfMeta: TrpcRouteMeta = {
openapi: {
method: 'GET',
path: '/envelope/{envelopeId}/audit-log/download',
summary: 'Download envelope audit log PDF',
description: 'Download the audit log for a document as a PDF.',
tags: ['Envelope'],
responseHeaders: z.object({
'Content-Type': z.literal('application/pdf'),
}),
},
};
export const ZDownloadEnvelopeAuditLogPdfRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'),
});
export const ZDownloadEnvelopeAuditLogPdfResponseSchema = z.instanceof(Uint8Array);
export type TDownloadEnvelopeAuditLogPdfRequest = z.infer<typeof ZDownloadEnvelopeAuditLogPdfRequestSchema>;
export type TDownloadEnvelopeAuditLogPdfResponse = z.infer<typeof ZDownloadEnvelopeAuditLogPdfResponseSchema>;
@@ -0,0 +1,23 @@
import { authenticatedProcedure } from '../trpc';
import {
downloadEnvelopeCertificatePdfMeta,
ZDownloadEnvelopeCertificatePdfRequestSchema,
ZDownloadEnvelopeCertificatePdfResponseSchema,
} from './download-envelope-certificate-pdf.types';
export const downloadEnvelopeCertificatePdfRoute = authenticatedProcedure
.meta(downloadEnvelopeCertificatePdfMeta)
.input(ZDownloadEnvelopeCertificatePdfRequestSchema)
.output(ZDownloadEnvelopeCertificatePdfResponseSchema)
.query(({ input, ctx }) => {
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
throw new Error('NOT_IMPLEMENTED');
});
@@ -0,0 +1,25 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../trpc';
export const downloadEnvelopeCertificatePdfMeta: TrpcRouteMeta = {
openapi: {
method: 'GET',
path: '/envelope/{envelopeId}/certificate/download',
summary: 'Download envelope certificate PDF',
description: 'Download the signing certificate for a completed document as a PDF.',
tags: ['Envelope'],
responseHeaders: z.object({
'Content-Type': z.literal('application/pdf'),
}),
},
};
export const ZDownloadEnvelopeCertificatePdfRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'),
});
export const ZDownloadEnvelopeCertificatePdfResponseSchema = z.instanceof(Uint8Array);
export type TDownloadEnvelopeCertificatePdfRequest = z.infer<typeof ZDownloadEnvelopeCertificatePdfRequestSchema>;
export type TDownloadEnvelopeCertificatePdfResponse = z.infer<typeof ZDownloadEnvelopeCertificatePdfResponseSchema>;
@@ -13,7 +13,7 @@ export const duplicateEnvelopeRoute = authenticatedProcedure
.output(ZDuplicateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { envelopeId } = input;
const { envelopeId, includeRecipients, includeFields } = input;
ctx.logger.info({
input: {
@@ -28,6 +28,10 @@ export const duplicateEnvelopeRoute = authenticatedProcedure
type: 'envelopeId',
id: envelopeId,
},
overrides: {
includeRecipients,
includeFields,
},
});
return {
@@ -13,7 +13,17 @@ export const duplicateEnvelopeMeta: TrpcRouteMeta = {
};
export const ZDuplicateEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().min(1).describe('The ID of the envelope to duplicate.'),
includeRecipients: z
.boolean()
.optional()
.default(true)
.describe('Whether to copy the recipients to the duplicated envelope. Defaults to true.'),
includeFields: z
.boolean()
.optional()
.default(true)
.describe('Whether to copy the fields to the duplicated envelope. Requires includeRecipients. Defaults to true.'),
});
export const ZDuplicateEnvelopeResponseSchema = z.object({
@@ -1,4 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
@@ -41,5 +42,26 @@ export const getEnvelopeRecipientRoute = authenticatedProcedure
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipient.envelopeId,
},
type: null,
userId: user.id,
teamId,
});
// Additional validation to check visibility.
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
return recipient;
});
@@ -0,0 +1,65 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { rejectDocumentOnBehalfOf } from '@documenso/lib/server-only/document/reject-document-on-behalf-of';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../../trpc';
import {
rejectEnvelopeRecipientOnBehalfOfMeta,
ZRejectEnvelopeRecipientOnBehalfOfRequestSchema,
ZRejectEnvelopeRecipientOnBehalfOfResponseSchema,
} from './reject-envelope-recipient-on-behalf-of.types';
export const rejectEnvelopeRecipientOnBehalfOfRoute = authenticatedProcedure
.meta(rejectEnvelopeRecipientOnBehalfOfMeta)
.input(ZRejectEnvelopeRecipientOnBehalfOfRequestSchema)
.output(ZRejectEnvelopeRecipientOnBehalfOfResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId, recipientId, reason, actAsEmail } = input;
ctx.logger.info({
input: {
envelopeId,
recipientId,
},
});
// This is an external-only action: it must only be reachable through the
// public API, never the internal app TRPC handler.
if (ctx.metadata.source !== 'apiV2') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'This route is only accessible via the public API',
});
}
await rejectDocumentOnBehalfOf({
envelopeId,
recipientId,
userId: user.id,
teamId,
reason,
actAsEmail,
requestMetadata: ctx.metadata,
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId,
});
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
include: {
fields: true,
},
});
return recipient;
});
@@ -0,0 +1,35 @@
import { ZEnvelopeRecipientSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
import type { TrpcRouteMeta } from '../../trpc';
export const rejectEnvelopeRecipientOnBehalfOfMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/recipient/{recipientId}/reject',
summary: 'Reject envelope recipient on behalf of',
description:
'Records a rejection on behalf of a recipient. Use this when a recipient has declined to ' +
'sign outside of the platform. The rejection is flagged as external in the document audit ' +
'log. By default the action is attributed to the API user; supply `actAsEmail` to attribute ' +
'it to a specific team member.',
tags: ['Envelope Recipients'],
},
};
export const ZRejectEnvelopeRecipientOnBehalfOfRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope the recipient belongs to.'),
recipientId: z.number().describe('The ID of the recipient to reject the document on behalf of.'),
reason: z.string().min(1).describe('The reason the recipient rejected the document.'),
actAsEmail: zEmail()
.optional()
.describe('The email of the team member to attribute the rejection to. Defaults to the API user when omitted.'),
});
export const ZRejectEnvelopeRecipientOnBehalfOfResponseSchema = ZEnvelopeRecipientSchema;
export type TRejectEnvelopeRecipientOnBehalfOfRequest = z.infer<typeof ZRejectEnvelopeRecipientOnBehalfOfRequestSchema>;
export type TRejectEnvelopeRecipientOnBehalfOfResponse = z.infer<
typeof ZRejectEnvelopeRecipientOnBehalfOfResponseSchema
>;
@@ -0,0 +1,95 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { reportSenderRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import { ZReportRecipientRequestSchema, ZReportRecipientResponseSchema } from './report-recipient.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
* Recipients report a sender directly from a link in their email, so no session or
* API token is required.
*/
export const reportRecipientRoute = procedure
.input(ZReportRecipientRequestSchema)
.output(ZReportRecipientResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token } = input;
if (!token) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Token is required',
});
}
const { ipAddress } = ctx.metadata.requestMetadata;
const recipient = await prisma.recipient.findFirst({
where: { token },
select: {
id: true,
envelopeId: true,
envelope: {
select: {
team: {
select: {
id: true,
organisationId: true,
},
},
},
},
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient could not be found',
});
}
// Rate limit to ensure we aren't double reporting by accident.
const rateLimitResult = await reportSenderRateLimit.check({
ip: ipAddress ?? 'unknown',
identifier: `${recipient.envelopeId}:${recipient.id}`,
});
if (rateLimitResult.isLimited) {
return;
}
const period = currentMonthlyPeriod();
const { organisationId } = recipient.envelope.team;
// Incrementing the stat is a non-critical side effect; fail soft so a transient
// DB error never turns reporting into a user-facing error.
await prisma.organisationMonthlyStat
.upsert({
where: {
organisationId_period: {
organisationId,
period,
},
},
update: {
emailReports: { increment: 1 },
},
create: {
id: generateDatabaseId('org_monthly_stat'),
organisationId,
period,
emailReports: 1,
},
})
.catch((error) => {
ctx.logger.error({
msg: 'Failed to increment organisation emailReports stat',
error,
});
});
ctx.logger.info({
msg: `Email reported. Recipient: ${recipient.id}. Envelope: ${recipient.envelopeId}.`,
});
});
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZReportRecipientRequestSchema = z.object({
token: z.string().min(1).describe('The recipient token from the email link used to report the sender.'),
});
export const ZReportRecipientResponseSchema = z.void();
export type TReportRecipientRequest = z.infer<typeof ZReportRecipientRequestSchema>;
@@ -1,4 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertEnvelopeMutable } from '@documenso/lib/server-only/envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
@@ -59,6 +60,8 @@ export const replaceEnvelopeItemPdfRoute = authenticatedProcedure
});
}
assertEnvelopeMutable(envelope);
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'PDF replacement is only supported for version 2 envelopes',
@@ -3,13 +3,17 @@ import { createAttachmentRoute } from './attachment/create-attachment';
import { deleteAttachmentRoute } from './attachment/delete-attachment';
import { findAttachmentsRoute } from './attachment/find-attachments';
import { updateAttachmentRoute } from './attachment/update-attachment';
import { bulkCancelEnvelopesRoute } from './bulk-cancel-envelopes';
import { bulkDeleteEnvelopesRoute } from './bulk-delete-envelopes';
import { bulkMoveEnvelopesRoute } from './bulk-move-envelopes';
import { cancelEnvelopeRoute } from './cancel-envelope';
import { createEnvelopeRoute } from './create-envelope';
import { createEnvelopeItemsRoute } from './create-envelope-items';
import { deleteEnvelopeRoute } from './delete-envelope';
import { deleteEnvelopeItemRoute } from './delete-envelope-item';
import { distributeEnvelopeRoute } from './distribute-envelope';
import { downloadEnvelopeAuditLogPdfRoute } from './download-envelope-audit-log-pdf';
import { downloadEnvelopeCertificatePdfRoute } from './download-envelope-certificate-pdf';
import { downloadEnvelopeItemRoute } from './download-envelope-item';
import { duplicateEnvelopeRoute } from './duplicate-envelope';
import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields';
@@ -20,6 +24,8 @@ import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fie
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
import { rejectEnvelopeRecipientOnBehalfOfRoute } from './envelope-recipients/reject-envelope-recipient-on-behalf-of';
import { reportRecipientRoute } from './envelope-recipients/report-recipient';
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
import { findEnvelopesRoute } from './find-envelopes';
@@ -69,6 +75,8 @@ export const envelopeRouter = router({
updateMany: updateEnvelopeRecipientsRoute,
delete: deleteEnvelopeRecipientRoute,
set: setEnvelopeRecipientsRoute,
report: reportRecipientRoute,
rejectOnBehalfOf: rejectEnvelopeRecipientOnBehalfOfRoute,
},
field: {
get: getEnvelopeFieldRoute,
@@ -82,10 +90,15 @@ export const envelopeRouter = router({
find: findEnvelopesRoute,
auditLog: {
find: findEnvelopeAuditLogsRoute,
downloadPdf: downloadEnvelopeAuditLogPdfRoute,
},
certificate: {
downloadPdf: downloadEnvelopeCertificatePdfRoute,
},
bulk: {
move: bulkMoveEnvelopesRoute,
delete: bulkDeleteEnvelopesRoute,
cancel: bulkCancelEnvelopesRoute,
},
editor: {
get: getEditorEnvelopeRoute,
@@ -96,6 +109,7 @@ export const envelopeRouter = router({
use: useEnvelopeRoute,
update: updateEnvelopeRoute,
delete: deleteEnvelopeRoute,
cancel: cancelEnvelopeRoute,
duplicate: duplicateEnvelopeRoute,
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
@@ -3,11 +3,10 @@ import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { generateStripeOrganisationCreateMetadata } from '@documenso/lib/utils/billing';
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { OrganisationType } from '@prisma/client';
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import { ZCreateOrganisationRequestSchema, ZCreateOrganisationResponseSchema } from './create-organisation.types';
@@ -43,18 +42,67 @@ export const createOrganisationRoute = authenticatedProcedure
}
}
// Create checkout session for payment.
// Create the organisation upfront, then redirect to checkout for payment.
// The webhook sync will attach the real subscription and claim after payment.
if (IS_BILLING_ENABLED() && priceId) {
const customer = await createCustomer({
email: user.email,
name: user.name || user.email,
const pendingOrganisation = await prisma.organisation.findFirst({
where: {
ownerUserId: user.id,
type: OrganisationType.ORGANISATION,
OR: [
{
subscription: {
is: null,
},
},
{
subscription: {
status: SubscriptionStatus.INACTIVE,
},
},
],
},
});
if (pendingOrganisation) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have a pending organisation awaiting payment. Complete or remove it before creating a new one.',
});
}
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
const organisation = await createOrganisation({
userId: user.id,
name,
type: OrganisationType.ORGANISATION,
claim: freeSubscriptionClaim,
});
let customerId = organisation.customerId;
if (!customerId) {
const customer = await createCustomer({
email: user.email,
name: user.name || user.email,
});
customerId = customer.id;
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
customerId,
},
});
}
const checkoutUrl = await createCheckoutSession({
priceId,
customerId: customer.id,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/organisations`,
subscriptionMetadata: generateStripeOrganisationCreateMetadata(name, user.id),
customerId,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`,
});
return {
@@ -66,11 +114,13 @@ export const createOrganisationRoute = authenticatedProcedure
// Free organisations should be Personal by default.
const organisationType = IS_BILLING_ENABLED() ? OrganisationType.PERSONAL : OrganisationType.ORGANISATION;
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
await createOrganisation({
userId: user.id,
name,
type: organisationType,
claim: internalClaims[INTERNAL_CLAIM_ID.FREE],
claim: freeSubscriptionClaim,
});
return {
@@ -1,6 +1,7 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationGroupType } from '@prisma/client';
@@ -59,6 +60,22 @@ export const deleteOrganisationGroupRoute = authenticatedProcedure
});
}
const currentUserOrganisationRole = await getMemberOrganisationRole({
organisationId,
reference: {
type: 'User',
id: user.id,
},
});
// A user cannot delete a group whose role is higher than their own
// (e.g. a manager deleting an admin-role group).
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, group.organisationRole)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to delete this organisation group',
});
}
await prisma.organisationGroup.delete({
where: {
id: groupId,
@@ -1,8 +1,15 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import {
ORGANISATION_MEMBER_ROLE_HIERARCHY,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
} from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import {
buildOrganisationWhereQuery,
getHighestOrganisationRoleInGroup,
isOrganisationRoleWithinUserHierarchy,
} from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
@@ -60,9 +67,12 @@ export const deleteOrganisationMembers = async ({
},
},
members: {
select: {
id: true,
userId: true,
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
},
},
invites: {
@@ -84,6 +94,41 @@ export const deleteOrganisationMembers = async ({
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
const currentUserMember = organisation.members.find((member) => member.userId === userId);
if (!currentUserMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const currentUserOrganisationRole = getHighestOrganisationRoleInGroup(
currentUserMember.organisationGroupMembers.map(({ group }) => group),
);
// The roles the current user is allowed to act on (their own role and below).
const manageableOrganisationRoles = ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole];
for (const member of membersToDelete) {
// The organisation owner can never be removed via this route. Ownership must
// be transferred first (mirrors the admin and update-member routes).
if (member.userId === organisation.ownerUserId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot remove the organisation owner',
});
}
const memberOrganisationRole = getHighestOrganisationRoleInGroup(
member.organisationGroupMembers.map(({ group }) => group),
);
// A user cannot remove a member whose role is higher than their own
// (e.g. a manager removing an admin).
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, memberOrganisationRole)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot remove a member with a higher role',
});
}
}
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
@@ -123,6 +168,18 @@ export const deleteOrganisationMembers = async ({
in: organisationMemberIds,
},
organisationId,
userId: {
not: organisation.ownerUserId,
},
organisationGroupMembers: {
none: {
group: {
organisationRole: {
notIn: manageableOrganisationRoles,
},
},
},
},
},
});
});
@@ -1,10 +1,6 @@
import {
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { orphanEnvelopes } from '@documenso/lib/server-only/envelope/orphan-envelopes';
import { deleteOrganisation } from '@documenso/lib/server-only/organisation/delete-organisation';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@@ -57,35 +53,5 @@ export const deleteOrganisationRoute = authenticatedProcedure
});
}
// Orphan all envelopes to get rid of foreign key constraints.
await Promise.all(organisation.teams.map(async (team) => orphanEnvelopes({ teamId: team.id })));
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
},
});
await tx.organisation.delete({
where: {
id: organisation.id,
},
});
});
// If the organisation has a Stripe subscription, schedule it to be
// cancelled at the end of the current billing period. The job runs
// asynchronously so a Stripe outage doesn't block deletion, and is
// retried by the job runner if Stripe is temporarily unavailable.
if (organisation.subscription) {
await jobs.triggerJob({
name: 'internal.cancel-organisation-subscription',
payload: {
stripeSubscriptionId: organisation.subscription.planId,
organisationId: organisation.id,
},
});
}
await deleteOrganisation({ organisation });
});
@@ -0,0 +1,55 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { computeQuotaFlags } from '@documenso/lib/server-only/rate-limit/compute-quota-flags';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationQuotaFlagsRequestSchema,
ZGetOrganisationQuotaFlagsResponseSchema,
} from './get-organisation-quota-flags.types';
export const getOrganisationQuotaFlagsRoute = authenticatedProcedure
.input(ZGetOrganisationQuotaFlagsRequestSchema)
.output(ZGetOrganisationQuotaFlagsResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId } = input;
const userId = ctx.user.id;
ctx.logger.info({
input: {
organisationId,
},
});
// Any member of the organisation may view quota usage flags.
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
}),
include: {
organisationClaim: true,
monthlyStats: {
where: {
period: currentMonthlyPeriod(),
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
return computeQuotaFlags({
quotas: {
documentQuota: organisation.organisationClaim.documentQuota,
emailQuota: organisation.organisationClaim.emailQuota,
apiQuota: organisation.organisationClaim.apiQuota,
},
usage: organisation.monthlyStats[0] ?? undefined,
});
});
@@ -0,0 +1,20 @@
import { z } from 'zod';
export const ZGetOrganisationQuotaFlagsRequestSchema = z.object({
organisationId: z.string().describe('The ID of the organisation.'),
});
/**
* Booleans only. Raw usage counts and quota caps are intentionally never
* surfaced to the client.
*/
export const ZGetOrganisationQuotaFlagsResponseSchema = z.object({
isDocumentQuotaExceeded: z.boolean(),
isEmailQuotaExceeded: z.boolean(),
isApiQuotaExceeded: z.boolean(),
isDocumentQuotaNearing: z.boolean(),
isEmailQuotaNearing: z.boolean(),
isApiQuotaNearing: z.boolean(),
});
export type TGetOrganisationQuotaFlagsResponse = z.infer<typeof ZGetOrganisationQuotaFlagsResponseSchema>;
@@ -51,6 +51,14 @@ export const leaveOrganisationRoute = authenticatedProcedure
throw new AppError(AppErrorCode.NOT_FOUND);
}
// The organisation owner cannot leave their own organisation. Ownership must
// be transferred to another member first.
if (organisation.ownerUserId === userId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You cannot leave an organisation you own. Please transfer ownership first.',
});
}
const { organisationClaim } = organisation;
const inviteCount = organisation.invites.length;
@@ -1,7 +1,8 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { sendOrganisationMemberInviteEmail } from '@documenso/lib/server-only/organisation/create-organisation-member-invites';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
@@ -97,6 +98,21 @@ export const resendOrganisationMemberInvitation = async ({
});
}
const currentUserOrganisationRole = await getMemberOrganisationRole({
organisationId: organisation.id,
reference: {
type: 'User',
id: userId,
},
});
// A user cannot interact with an invitation that is not within their own hierarchy.
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, invitation.organisationRole)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You cannot resend an invite for a member with a higher role',
});
}
await sendOrganisationMemberInviteEmail({
email: invitation.email,
token: invitation.token,
@@ -14,6 +14,7 @@ import { findOrganisationMemberInvitesRoute } from './find-organisation-member-i
import { findOrganisationMembersRoute } from './find-organisation-members';
import { getOrganisationRoute } from './get-organisation';
import { getOrganisationMemberInvitesRoute } from './get-organisation-member-invites';
import { getOrganisationQuotaFlagsRoute } from './get-organisation-quota-flags';
import { getOrganisationSessionRoute } from './get-organisation-session';
import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation';
@@ -26,6 +27,7 @@ import { updateOrganisationSettingsRoute } from './update-organisation-settings'
export const organisationRouter = router({
get: getOrganisationRoute,
getMany: getOrganisationsRoute,
getQuotaFlags: getOrganisationQuotaFlagsRoute,
create: createOrganisationRoute,
update: updateOrganisationRoute,
delete: deleteOrganisationRoute,
@@ -38,6 +38,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure
},
include: {
organisationGroupMembers: true,
organisation: {
include: {
members: {
select: {
id: true,
},
},
},
},
},
});
@@ -78,6 +87,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure
const groupMemberIds = unique(data.memberIds || []);
// Validate that members belong to the same organisation as the group.
groupMemberIds.forEach((memberId) => {
const member = organisationGroup.organisation.members.find(({ id }) => id === memberId);
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
});
const membersToDelete = organisationGroup.organisationGroupMembers.filter(
(member) => !groupMemberIds.includes(member.organisationMemberId),
);
@@ -36,6 +36,12 @@ export const profileRouter = router({
}),
deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => {
ctx.logger.info({
input: {
userId: ctx.user.id,
},
});
await deleteUser({
id: ctx.user.id,
});
@@ -1,3 +1,4 @@
import { prepareCscRecipientSigning } from '@documenso/ee/server-only/signing/csc/prepare-recipient-signing';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
@@ -6,6 +7,9 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
@@ -13,6 +17,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
import {
ZCompleteDocumentWithTokenMutationSchema,
ZCompleteDocumentWithTokenResponseSchema,
ZCreateDocumentRecipientRequestSchema,
ZCreateDocumentRecipientResponseSchema,
ZCreateDocumentRecipientsRequestSchema,
@@ -559,6 +564,7 @@ export const recipientRouter = router({
*/
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.output(ZCompleteDocumentWithTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input;
@@ -568,6 +574,25 @@ export const recipientRouter = router({
},
});
// Branch on TSP envelopes before any SES side effects: TSP recipients
// 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({
where: {
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
recipients: { some: { token } },
},
select: { signatureLevel: true, internalVersion: true },
});
if (isTspEnvelope(envelope)) {
return await prepareCscRecipientSigning({
recipientToken: token,
requestMetadata: ctx.metadata.requestMetadata,
});
}
await completeDocumentWithToken({
token,
id: {
@@ -580,6 +605,8 @@ export const recipientRouter = router({
userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata,
});
return { status: 'SIGNED' as const };
}),
/**
@@ -178,6 +178,20 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
export type TCompleteDocumentWithTokenMutationSchema = z.infer<typeof ZCompleteDocumentWithTokenMutationSchema>;
/**
* Discriminated response: SES envelopes return `{ status: 'SIGNED' }` after
* the in-place completion; TSP (AES/QES) envelopes return
* `{ status: 'REDIRECT', redirectUrl }` pointing at the credential-scope
* OAuth authorize endpoint. Frontend callers can branch on `status` —
* existing callers ignored the response and remain compatible.
*/
export const ZCompleteDocumentWithTokenResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('REDIRECT'), redirectUrl: z.string() }),
z.object({ status: z.literal('SIGNED') }),
]);
export type TCompleteDocumentWithTokenResponseSchema = z.infer<typeof ZCompleteDocumentWithTokenResponseSchema>;
export const ZRejectDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),
@@ -53,6 +53,15 @@ export const deleteTeamGroupRoute = authenticatedProcedure
});
}
// You cannot delete internal team groups. These are the system-managed
// admin/manager/member groups that back the team's role-based access, and
// deleting them would silently strip team members of their access.
if (group.organisationGroup.type === OrganisationGroupType.INTERNAL_TEAM) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to delete internal team groups',
});
}
// You cannot delete internal organisation groups.
// The only exception is deleting the "member" organisation group which is used to allow
// all organisation members to access a team.
@@ -45,9 +45,12 @@ export const updateTeamGroupRoute = authenticatedProcedure
});
}
if (teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_ORGANISATION) {
if (
teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_ORGANISATION ||
teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_TEAM
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to update internal organisation groups',
message: 'You are not allowed to update internal groups',
});
}
+34 -8
View File
@@ -1,5 +1,6 @@
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { assertUserNotDisabled } from '@documenso/lib/server-only/user/assert-user-not-disabled';
import type { TrpcApiLog } from '@documenso/lib/types/api-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { alphaid } from '@documenso/lib/universal/id';
@@ -96,6 +97,10 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
const apiToken = await getApiTokenByToken({ token });
// Reject API requests from a disabled account. The token may still be
// present in the DB (e.g. before `disableUser` runs) so we enforce here.
assertUserNotDisabled(apiToken.user);
const trpcApiV2Logger = ctx.logger.child({
...baseLogAttributes,
auth: 'api',
@@ -140,6 +145,11 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
});
}
// Reject session requests from a disabled account. The session may still be
// valid (sessions aren't invalidated by `disableUser`), so we gate every
// authenticated TRPC call here.
assertUserNotDisabled(ctx.user);
// Recreate the logger with a sub request ID to differentiate between batched
// requests, as well as identifying attributes so every subsequent log line
// (including errors) inherits them.
@@ -199,6 +209,11 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
const apiToken = await getApiTokenByToken({ token });
// Reject API requests from a disabled account. Presenting an API token is
// an explicit attempt to act under that account, so we don't downgrade to
// anonymous here — we reject.
assertUserNotDisabled(apiToken.user);
// Attach identifying attributes to the logger so every subsequent log line
// within this request (including errors) inherits them.
const trpcApiV2Logger = ctx.logger.child({
@@ -238,9 +253,17 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
});
}
// Treat a disabled session as anonymous. Most routes wired through
// `maybeAuthenticatedProcedure` are signer/invite flows that key off an
// input token rather than `ctx.user`, so downgrading lets those keep
// working while routes that genuinely need an account naturally fall
// through to their own auth checks.
const sessionUser = ctx.user && !ctx.user.disabled ? ctx.user : null;
const sessionRecord = sessionUser ? ctx.session : null;
// Resolve `auth` once so it stays in sync between the logger bindings and
// the outgoing metadata.
const auth = ctx.session ? 'session' : null;
const auth = sessionRecord ? 'session' : null;
// Recreate the logger with a sub request ID to differentiate between batched
// requests, as well as identifying attributes so every subsequent log line
@@ -249,7 +272,7 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
...baseLogAttributes,
auth,
nonBatchedRequestId: alphaid(),
userId: ctx.user?.id,
userId: sessionUser?.id,
apiTokenId: null,
} satisfies TrpcApiLog);
@@ -261,15 +284,15 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
ctx: {
...ctx,
logger: trpcSessionLogger,
user: ctx.user,
session: ctx.session,
user: sessionUser,
session: sessionRecord,
metadata: {
...ctx.metadata,
auditUser: ctx.user
auditUser: sessionUser
? {
id: ctx.user.id,
name: ctx.user.name,
email: ctx.user.email,
id: sessionUser.id,
name: sessionUser.name,
email: sessionUser.email,
}
: undefined,
auth,
@@ -286,6 +309,9 @@ export const adminMiddleware = t.middleware(async ({ ctx, next, path }) => {
});
}
// Disabled admins shouldn't be able to do anything either.
assertUserNotDisabled(ctx.user);
const isUserAdmin = isAdmin(ctx.user);
if (!isUserAdmin) {