mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: update new quota and rates UX (#2954)
This commit is contained in:
+18
-1
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user