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

This commit is contained in:
ephraimduncan
2026-07-02 15:36:15 +00:00
632 changed files with 39543 additions and 6691 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 {
@@ -1,11 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateAdminOrganisationRequestSchema = z.object({
ownerUserId: z.number(),
data: z.object({
name: ZOrganisationNameSchema,
name: ZNameSchema,
}),
});
@@ -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,12 +1,25 @@
import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
import { z } from 'zod';
export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: z.string().min(1),
name: ZNameSchema,
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();
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateUserRequestSchema = z.object({
@@ -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,16 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({
name: ZNameSchema,
fromName: ZNameSchema,
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,34 @@
import {
ZMailChannelsConfigSchema,
ZResendConfigSchema,
ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
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: ZNameSchema,
fromName: ZNameSchema,
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>;
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
name: true,
url: true,
ownerUserId: true,
organisationGlobalSettings: true,
},
},
teamEmail: true,
@@ -1,5 +1,6 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true,
url: true,
ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}),
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
@@ -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>;
@@ -1,19 +1,27 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateAdminOrganisationRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
name: ZOrganisationNameSchema.optional(),
name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(),
claims: ZCreateSubscriptionClaimRequestSchema.pick({
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();
@@ -1,10 +1,11 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { Role } from '@prisma/client';
import { z } from 'zod';
export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
name: ZNameSchema.nullish(),
email: zEmail().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZCreateApiTokenRequestSchema = z.object({
teamId: z.number(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
tokenName: ZNameSchema,
expirationDate: z.string().nullable(),
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { z } from 'zod';
export const ZCreatePasskeyRequestSchema = z.object({
passkeyName: z.string().trim().min(1),
passkeyName: ZNameSchema,
verificationResponse: ZRegistrationResponseJSONSchema,
});
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZUpdatePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1),
name: ZNameSchema,
});
export const ZUpdatePasskeyResponseSchema = 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,
},
@@ -1,9 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(),
emailName: z.string().min(1).max(100),
emailName: ZNameSchema,
email: zEmail().toLowerCase(),
// This does not need to be validated to be part of the domain.
@@ -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';
@@ -66,6 +72,8 @@ export const envelopeRouter = router({
updateMany: updateEnvelopeRecipientsRoute,
delete: deleteEnvelopeRecipientRoute,
set: setEnvelopeRecipientsRoute,
report: reportRecipientRoute,
rejectOnBehalfOf: rejectEnvelopeRecipientOnBehalfOfRoute,
},
field: {
get: getEnvelopeFieldRoute,
@@ -79,10 +87,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,
@@ -93,6 +106,7 @@ export const envelopeRouter = router({
use: useEnvelopeRoute,
update: updateEnvelopeRoute,
delete: deleteEnvelopeRoute,
cancel: cancelEnvelopeRoute,
duplicate: duplicateEnvelopeRoute,
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
+3 -2
View File
@@ -1,4 +1,5 @@
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
@@ -42,7 +43,7 @@ const ZFolderParentIdSchema = z
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
export const ZCreateFolderRequestSchema = z.object({
name: z.string(),
name: ZNameSchema,
parentId: ZFolderParentIdSchema.optional(),
type: ZFolderTypeSchema.optional(),
});
@@ -52,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'),
data: z.object({
name: z.string().optional().describe('The name of the folder'),
name: ZNameSchema.optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZCreateOrganisationGroupRequestSchema = z.object({
organisationId: z.string(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
name: z.string().max(100),
name: ZNameSchema,
memberIds: z.array(z.string()),
});
@@ -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,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
// export const createOrganisationMeta: TrpcOpenApiMeta = {
@@ -10,13 +11,8 @@ import { z } from 'zod';
// },
// };
export const ZOrganisationNameSchema = z
.string()
.min(3, { message: 'Minimum 3 characters' })
.max(50, { message: 'Maximum 50 characters' });
export const ZCreateOrganisationRequestSchema = z.object({
name: ZOrganisationNameSchema,
name: ZNameSchema,
priceId: z.string().optional(),
});
@@ -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,11 +14,13 @@ 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';
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
import { updateOrganisationRoute } from './update-organisation';
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
import { updateOrganisationGroupRoute } from './update-organisation-group';
import { updateOrganisationMemberRoute } from './update-organisation-members';
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
@@ -26,6 +28,7 @@ import { updateOrganisationSettingsRoute } from './update-organisation-settings'
export const organisationRouter = router({
get: getOrganisationRoute,
getMany: getOrganisationsRoute,
getQuotaFlags: getOrganisationQuotaFlagsRoute,
create: createOrganisationRoute,
update: updateOrganisationRoute,
delete: deleteOrganisationRoute,
@@ -53,6 +56,7 @@ export const organisationRouter = router({
},
settings: {
update: updateOrganisationSettingsRoute,
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
},
internal: {
getOrganisationSession: getOrganisationSessionRoute,
@@ -0,0 +1,69 @@
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 { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationBrandingLogoRequestSchema,
ZUpdateOrganisationBrandingLogoResponseSchema,
} from './update-organisation-branding-logo.types';
export const updateOrganisationBrandingLogoRoute = authenticatedProcedure
.input(ZUpdateOrganisationBrandingLogoRequestSchema)
.output(ZUpdateOrganisationBrandingLogoResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { payload, brandingLogo } = input;
const { organisationId } = payload;
ctx.logger.info({
input: {
organisationId,
},
});
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this organisation.',
});
}
// Setting a logo requires the custom-branding entitlement; clearing it is
// always allowed so a downgraded organisation can still remove its logo.
if (brandingLogo && IS_BILLING_ENABLED()) {
const claim = await getOrganisationClaim({ organisationId });
if (claim.flags?.allowCustomBranding !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Your plan does not allow custom branding.',
});
}
}
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
organisationGlobalSettings: {
update: {
brandingLogo: brandingLogoValue,
},
},
},
});
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({
payload: zfd.json(
z.object({
organisationId: z.string(),
}),
),
brandingLogo: zfdBrandingImageFile().optional(),
});
export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void();
export type TUpdateOrganisationBrandingLogoRequest = z.infer<typeof ZUpdateOrganisationBrandingLogoRequestSchema>;
@@ -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),
);
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZUpdateOrganisationGroupRequestSchema = z.object({
id: z.string(),
name: z.string().nullable().optional(),
name: ZNameSchema.nullable().optional(),
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
memberIds: z.array(z.string()).optional(),
});
@@ -45,7 +45,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -176,7 +175,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
@@ -33,7 +33,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
// Branding related settings.
brandingEnabled: z.boolean().optional(),
brandingLogo: z.string().optional(),
brandingUrl: z.string().optional(),
brandingCompanyDetails: z.string().optional(),
brandingColors: ZCssVarsSchema.nullish(),
@@ -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,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
export const ZFindUserSecurityAuditLogsSchema = z.object({
@@ -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(),
@@ -1,5 +1,6 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
import { ZTeamUrlSchema } from './schema';
// export const createTeamMeta: TrpcOpenApiMeta = {
// openapi: {
@@ -13,7 +14,7 @@ import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
export const ZCreateTeamRequestSchema = z.object({
organisationId: z.string(),
teamName: ZTeamNameSchema,
teamName: ZNameSchema,
teamUrl: ZTeamUrlSchema,
inheritMembers: z
.boolean()
@@ -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.
@@ -25,6 +25,7 @@ import {
ZUpdateTeamEmailMutationSchema,
} from './schema';
import { updateTeamRoute } from './update-team';
import { updateTeamBrandingLogoRoute } from './update-team-branding-logo';
import { updateTeamGroupRoute } from './update-team-group';
import { updateTeamMemberRoute } from './update-team-member';
import { updateTeamSettingsRoute } from './update-team-settings';
@@ -50,6 +51,7 @@ export const teamRouter = router({
},
settings: {
update: updateTeamSettingsRoute,
updateBrandingLogo: updateTeamBrandingLogoRoute,
},
// Old routes (to be migrated)
+1 -10
View File
@@ -1,5 +1,5 @@
import { URL_PATTERN, ZNameSchema } from '@documenso/lib/constants/auth';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod';
import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
@@ -32,15 +32,6 @@ export const ZTeamUrlSchema = z
message: 'This URL is already in use.',
});
export const ZTeamNameSchema = z
.string()
.trim()
.min(3, { message: 'Team name must be at least 3 characters long.' })
.max(30, { message: 'Team name must not exceed 30 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Team name cannot contain URLs.',
});
export const ZCreateTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(),
name: ZNameSchema,
@@ -0,0 +1,69 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateTeamBrandingLogoRequestSchema,
ZUpdateTeamBrandingLogoResponseSchema,
} from './update-team-branding-logo.types';
export const updateTeamBrandingLogoRoute = authenticatedProcedure
.input(ZUpdateTeamBrandingLogoRequestSchema)
.output(ZUpdateTeamBrandingLogoResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { payload, brandingLogo } = input;
const { teamId } = payload;
ctx.logger.info({
input: {
teamId,
},
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId: user.id,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this team.',
});
}
// Setting a logo requires the custom-branding entitlement; clearing it is
// always allowed so a downgraded team can still remove its logo.
if (brandingLogo && IS_BILLING_ENABLED()) {
const claim = await getOrganisationClaimByTeamId({ teamId });
if (claim.flags?.allowCustomBranding !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Your plan does not allow custom branding.',
});
}
}
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
await prisma.team.update({
where: {
id: team.id,
},
data: {
teamGlobalSettings: {
update: {
brandingLogo: brandingLogoValue,
},
},
},
});
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
export const ZUpdateTeamBrandingLogoRequestSchema = zodFormData({
payload: zfd.json(
z.object({
teamId: z.number(),
}),
),
brandingLogo: zfdBrandingImageFile().optional(),
});
export const ZUpdateTeamBrandingLogoResponseSchema = z.void();
export type TUpdateTeamBrandingLogoRequest = z.infer<typeof ZUpdateTeamBrandingLogoRequestSchema>;
@@ -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',
});
}
@@ -43,7 +43,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -178,7 +177,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
@@ -36,7 +36,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
// Branding related settings.
brandingEnabled: z.boolean().nullish(),
brandingLogo: z.string().nullish(),
brandingUrl: z.string().nullish(),
brandingCompanyDetails: z.string().nullish(),
brandingColors: ZCssVarsSchema.nullish(),
@@ -1,6 +1,7 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod';
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
import { ZTeamUrlSchema } from './schema';
export const MAX_PROFILE_BIO_LENGTH = 256;
@@ -19,7 +20,7 @@ export const MAX_PROFILE_BIO_LENGTH = 256;
export const ZUpdateTeamRequestSchema = z.object({
teamId: z.number(),
data: z.object({
name: ZTeamNameSchema.optional(),
name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(),
profileBio: z
.string()

Some files were not shown because too many files have changed in this diff Show More