fix: update new quota and rates UX (#2954)

This commit is contained in:
David Nguyen
2026-06-08 14:14:22 +10:00
committed by GitHub
parent 03b5fe6117
commit 8448e333cf
22 changed files with 459 additions and 110 deletions
@@ -2,7 +2,6 @@ import OrganisationLimitExceededEmailTemplate from '@documenso/email/templates/o
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
@@ -94,4 +93,22 @@ export const run = async ({
});
});
}
// Todo: Logging
// Todo: Decide if we want to send an email or alert via another software.
// const i18n = await getI18nInstance('en');
// // Email our support team.
// await mailer.sendMail({
// to: SUPPORT_EMAIL,
// from: senderEmail,
// subject: i18n._(msg`An organisation has exceeded their fair use limits`),
// text: `
// Organisation: ${organisation.name}
// Organisation ID: ${organisation.id}
// Counter: ${payload.counter}
// Kind: ${payload.kind}
// Period: ${payload.period}
// `,
// });
};
@@ -0,0 +1,47 @@
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
isEmailQuotaExceeded: boolean;
isApiQuotaExceeded: boolean;
};
type ComputeQuotaFlagsOptions = {
quotas: {
documentQuota: number | null;
emailQuota: number | null;
apiQuota: number | null;
};
usage?: {
documentCount?: number;
emailCount?: number;
apiCount?: number;
};
};
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*
* Note: this `>=` is intentionally the "reached" signal for the banner and is
* distinct from enforcement in `check-monthly-quota.ts`, which blocks the
* action that crosses the boundary using a strict `>` on the post-increment
* count. Do not "align" them — they answer different questions.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
isEmailQuotaExceeded: isQuotaExceeded(quotas.emailQuota, usage?.emailCount ?? 0),
isApiQuotaExceeded: isQuotaExceeded(quotas.apiQuota, usage?.apiCount ?? 0),
};
};
@@ -1,16 +1,5 @@
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
export type LimitCounter = 'api' | 'document' | 'email';
export type LimitOptions = {
organisationId: string;
organisationClaim?: OrganisationClaim;
monthlyStat?: OrganisationMonthlyStat;
// Units to reserve. Default 1. Must be >= 1.
count?: number;
};
export type RateLimitEntry = {
window: `${number}${'s' | 'm' | 'h' | 'd'}`;
max: number;
+1
View File
@@ -20,6 +20,7 @@ export const ZOrganisationSchema = OrganisationSchema.pick({
originalSubscriptionClaimId: true,
teamCount: true,
memberCount: true,
recipientCount: true,
flags: true,
}),
});
@@ -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,17 @@
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(),
});
export type TGetOrganisationQuotaFlagsResponse = z.infer<typeof ZGetOrganisationQuotaFlagsResponseSchema>;
@@ -14,6 +14,7 @@ import { findOrganisationMemberInvitesRoute } from './find-organisation-member-i
import { findOrganisationMembersRoute } from './find-organisation-members';
import { getOrganisationRoute } from './get-organisation';
import { getOrganisationMemberInvitesRoute } from './get-organisation-member-invites';
import { getOrganisationQuotaFlagsRoute } from './get-organisation-quota-flags';
import { getOrganisationSessionRoute } from './get-organisation-session';
import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation';
@@ -26,6 +27,7 @@ import { updateOrganisationSettingsRoute } from './update-organisation-settings'
export const organisationRouter = router({
get: getOrganisationRoute,
getMany: getOrganisationsRoute,
getQuotaFlags: getOrganisationQuotaFlagsRoute,
create: createOrganisationRoute,
update: updateOrganisationRoute,
delete: deleteOrganisationRoute,
@@ -29,6 +29,7 @@ import { prop, sortBy } from 'remeda';
import { DocumentReadOnlyFields, mapFieldsWithRecipients } from '../../components/document/document-read-only-fields';
import type { RecipientAutoCompleteOption } from '../../components/recipient/recipient-autocomplete-input';
import { RecipientAutoCompleteInput } from '../../components/recipient/recipient-autocomplete-input';
import { Alert, AlertDescription } from '../alert';
import { Button } from '../button';
import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
@@ -491,10 +492,24 @@ export const AddSignersFormPartial = ({
void handleAutoSave();
}, [form]);
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
return (
<>
<DocumentFlowFormContainerHeader title={documentFlow.title} description={documentFlow.description} />
<DocumentFlowFormContainerContent>
{isOverRecipientLimit && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>
<Trans>
This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need
more.
</Trans>
</AlertDescription>
</Alert>
)}
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
showRecipientColors={true}