diff --git a/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx b/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx
index c25f23bb3..b2a5e1a93 100644
--- a/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx
+++ b/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx
@@ -3,6 +3,7 @@ import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -38,12 +39,18 @@ export const OrganisationQuotaBanner = () => {
quotaFlags?.isDocumentQuotaExceeded || quotaFlags?.isEmailQuotaExceeded || quotaFlags?.isApiQuotaExceeded,
);
- // Every member of the organisation sees the banner when a quota is exhausted.
+ const isAnyQuotaNearing = Boolean(
+ quotaFlags?.isDocumentQuotaNearing || quotaFlags?.isEmailQuotaNearing || quotaFlags?.isApiQuotaNearing,
+ );
+
+ // Every member of the organisation sees the banner when a quota is exhausted or
+ // nearing its limit. When both states apply, "exceeded" wins for the banner copy
+ // and the dialog lists both exceeded and nearing items.
// Note: Skipping free plan banner for now because their quota can incorrectly show as exceeded.
if (
!organisation ||
!quotaFlags ||
- !isAnyQuotaExceeded ||
+ (!isAnyQuotaExceeded && !isAnyQuotaNearing) ||
organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE
) {
return null;
@@ -51,17 +58,29 @@ export const OrganisationQuotaBanner = () => {
return (
<>
-
+
- Your organisation has exceeded a fair use limit
+ {isAnyQuotaExceeded ? (
+ Your organisation has exceeded a fair use limit
+ ) : (
+ Your organisation is approaching a fair use limit
+ )}
- {_(previewText)}
-
-
-
-
-
-
-
-
- Organisation Review Required
-
-
- {kind === 'quota' ? (
-
- {match(counter)
- .with('document', () => (
-
- We've noticed document activity on your account that exceeds the fair use limits of your current
- plan. As a precaution, new document activity has been temporarily paused pending review.
-
- ))
- .with('email', () => (
-
- We've noticed email sending activity on your account that exceeds the fair use limits of your
- current plan. As a precaution, new email activity has been temporarily paused pending review.
-
- ))
- .with('api', () => (
-
- We've noticed API activity on your account that exceeds the fair use limits of your current
- plan. As a precaution, new API activity has been temporarily paused pending review.
-
- ))
- .exhaustive()}
-
- ) : (
-
- {match(counter)
- .with('document', () => (
-
- Your organisation is generating documents faster than normal, so some requests are being
- temporarily throttled.
-
- ))
- .with('email', () => (
-
- Your organisation is generating emails faster than normal, so some requests are being
- temporarily throttled.
-
- ))
- .with('api', () => (
-
- Your organisation is generating API requests faster than normal, so some requests are being
- temporarily throttled.
-
- ))
- .exhaustive()}
-
- )}
-
-
- Please contact support at {SUPPORT_EMAIL} and we will review your account.
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default OrganisationLimitExceededEmailTemplate;
diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts
index 083c28424..ae23cb092 100644
--- a/packages/lib/jobs/client.ts
+++ b/packages/lib/jobs/client.ts
@@ -4,7 +4,7 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-completed-emails';
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
-import { SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-exceeded-email';
+import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
@@ -37,7 +37,7 @@ export const jobsClient = new JobClient([
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
- SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION,
+ SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
diff --git a/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.handler.ts
similarity index 51%
rename from packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts
rename to packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.handler.ts
index ea4ebe36b..d43a661b1 100644
--- a/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts
+++ b/packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.handler.ts
@@ -1,21 +1,25 @@
-import OrganisationLimitExceededEmailTemplate from '@documenso/email/templates/organisation-limit-exceeded';
+import { mailer } from '@documenso/email/mailer';
+import OrganisationLimitAlertEmailTemplate from '@documenso/email/templates/organisation-limit-alert';
+import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
+
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 { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { INTERNAL_CLAIM_ID } from '../../../types/subscription';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
-import type { TSendOrganisationLimitExceededEmailJobDefinition } from './send-organisation-limit-exceeded-email';
+import type { TSendOrganisationLimitAlertEmailJobDefinition } from './send-organisation-limit-alert-email';
export const run = async ({
payload,
io,
}: {
- payload: TSendOrganisationLimitExceededEmailJobDefinition;
+ payload: TSendOrganisationLimitAlertEmailJobDefinition;
io: JobRunIO;
}) => {
const organisation = await prisma.organisation.findFirstOrThrow({
@@ -24,6 +28,16 @@ export const run = async ({
},
include: {
organisationClaim: true,
+ monthlyStats: {
+ where: {
+ period: payload.period,
+ },
+ select: {
+ documentCount: true,
+ emailCount: true,
+ apiCount: true,
+ },
+ },
members: {
where: {
organisationGroupMembers: {
@@ -60,16 +74,19 @@ export const run = async ({
// Do not send emails for "free" claims.
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE) {
io.logger.info({
- msg: 'Skipping organisation limit exceeded email for "free" claim',
+ msg: 'Skipping organisation limit alert email for "free" claim',
organisationId: organisation.id,
});
return;
}
+ const memberSubject =
+ payload.kind === 'quotaNearing' ? msg`Approaching Your Plan Limits` : msg`Organisation Review Required`;
+
for (const member of organisation.members) {
- await io.runTask(`send-organisation-limit-exceeded-email-${member.id}`, async () => {
- const emailContent = createElement(OrganisationLimitExceededEmailTemplate, {
+ await io.runTask(`send-organisation-limit-alert-email-${member.id}`, async () => {
+ const emailContent = createElement(OrganisationLimitAlertEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
organisationName: organisation.name,
counter: payload.counter,
@@ -87,7 +104,7 @@ export const run = async ({
await emailTransport.sendMail({
to: member.user.email,
from: senderEmail,
- subject: i18n._(msg`Organisation Review Required`),
+ subject: i18n._(memberSubject),
html,
text,
});
@@ -95,20 +112,31 @@ 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');
+ 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}
- // `,
- // });
+ const supportSubject =
+ payload.kind === 'quotaNearing'
+ ? msg`An organisation is nearing their fair use limits`
+ : msg`An organisation has exceeded their fair use limits`;
+
+ // Email our support team. Purposefully sent from the internal email since the
+ // global mailer is not authorized to send from custom per-plan transport addresses.
+ await io.runTask('send-organisation-limit-alert-support-email', async () => {
+ await mailer.sendMail({
+ to: SUPPORT_EMAIL,
+ from: DOCUMENSO_INTERNAL_EMAIL,
+ subject: i18n._(supportSubject),
+ text: `
+ Organisation: ${organisation.name}
+ Organisation ID: ${organisation.id}
+ Organisation Claim Original ID: ${organisation.organisationClaim.originalSubscriptionClaimId}
+ Email Quota: ${organisation.monthlyStats[0]?.emailCount || 0}/${organisation.organisationClaim.emailQuota ?? 'Unlimited'}
+ API Quota: ${organisation.monthlyStats[0]?.apiCount || 0}/${organisation.organisationClaim.apiQuota ?? 'Unlimited'}
+ Document Quota: ${organisation.monthlyStats[0]?.documentCount || 0}/${organisation.organisationClaim.documentQuota ?? 'Unlimited'}
+ Counter: ${payload.counter}
+ Kind: ${payload.kind}
+ Period: ${payload.period}
+ `,
+ });
+ });
};
diff --git a/packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.ts b/packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.ts
new file mode 100644
index 000000000..e4b14f1ef
--- /dev/null
+++ b/packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.ts
@@ -0,0 +1,34 @@
+import { z } from 'zod';
+
+import type { JobDefinition } from '../../client/_internal/job';
+
+const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limit-alert.email';
+
+const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
+ organisationId: z.string(),
+ counter: z.enum(['document', 'email', 'api']),
+ kind: z.enum(['rateLimit', 'quota', 'quotaNearing']),
+ period: z.string(),
+});
+
+export type TSendOrganisationLimitAlertEmailJobDefinition = z.infer<
+ typeof SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA
+>;
+
+export const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION = {
+ id: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
+ name: 'Send Organisation Limit Alert Email',
+ version: '1.0.0',
+ trigger: {
+ name: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
+ schema: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA,
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./send-organisation-limit-alert-email.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
+ TSendOrganisationLimitAlertEmailJobDefinition
+>;
diff --git a/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.ts b/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.ts
deleted file mode 100644
index 06595c8b5..000000000
--- a/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { z } from 'zod';
-
-import type { JobDefinition } from '../../client/_internal/job';
-
-const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limit-exceeded.email';
-
-const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
- organisationId: z.string(),
- counter: z.enum(['document', 'email', 'api']),
- kind: z.enum(['rateLimit', 'quota']),
- period: z.string(),
-});
-
-export type TSendOrganisationLimitExceededEmailJobDefinition = z.infer<
- typeof SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA
->;
-
-export const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION = {
- id: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
- name: 'Send Organisation Limit Exceeded Email',
- version: '1.0.0',
- trigger: {
- name: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
- schema: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA,
- },
- handler: async ({ payload, io }) => {
- const handler = await import('./send-organisation-limit-exceeded-email.handler');
-
- await handler.run({ payload, io });
- },
-} as const satisfies JobDefinition<
- typeof SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
- TSendOrganisationLimitExceededEmailJobDefinition
->;
diff --git a/packages/lib/server-only/rate-limit/check-monthly-quota.ts b/packages/lib/server-only/rate-limit/check-monthly-quota.ts
index 26dd70774..9066a2c95 100644
--- a/packages/lib/server-only/rate-limit/check-monthly-quota.ts
+++ b/packages/lib/server-only/rate-limit/check-monthly-quota.ts
@@ -3,6 +3,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobsClient } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
import { currentMonthlyPeriod } from '../../universal/monthly-period';
+import { getQuotaAlertKind } from './get-quota-alert-kind';
import type { LimitCounter } from './types';
type CheckMonthlyQuotaOptions = {
@@ -56,29 +57,33 @@ export const checkMonthlyQuota = async (opts: CheckMonthlyQuotaOptions): Promise
const newCount = latestMonthlyStat[column];
const previousCount = newCount - opts.count;
- const isOverQuota = newCount > opts.quota;
+ // Returns 'quota' on the single request that reached (or jumped past) the quota,
+ // 'quotaNearing' on the single request that reached the warning threshold,
+ // otherwise null. See getQuotaAlertKind for the exactly-once guarantee.
+ const alertKind = getQuotaAlertKind({
+ previousCount,
+ newCount,
+ quota: opts.quota,
+ });
- // Only notify on the single request that crossed the threshold: the count was
- // at/under quota before this request and over it after. Because the DB
- // serializes the atomic increment, the post-increment values are distinct and
- // monotonic, so exactly one request's (previousCount, newCount] interval
- // contains the quota boundary — guaranteeing the notification fires once.
- const didCrossQuota = isOverQuota && previousCount <= opts.quota;
-
- if (didCrossQuota) {
+ // Trigger the alert before the over-quota check — the 'quota' alert usually fires
+ // on the successful request that consumes the last unit of allowance, but when a
+ // batch jumps past the boundary it fires on this rejected request. Either way it
+ // will never fire again this period, so it must be enqueued before any throw.
+ if (alertKind) {
await jobsClient
.triggerJob({
- name: 'send.organisation-limit-exceeded.email',
+ name: 'send.organisation-limit-alert.email',
payload: {
organisationId: opts.organisationId,
counter: opts.counter,
- kind: 'quota',
+ kind: alertKind,
period,
},
})
.catch((error) => {
console.error({
- msg: 'Failed to send organisation limit exceeded email',
+ msg: 'Failed to send organisation limit alert email',
error,
});
@@ -86,7 +91,7 @@ export const checkMonthlyQuota = async (opts: CheckMonthlyQuotaOptions): Promise
});
}
- if (isOverQuota) {
+ if (newCount > opts.quota) {
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message:
'Your request could not be completed at this time due to your account exceeding the fair use limits of your current plan. Please contact support.',
diff --git a/packages/lib/server-only/rate-limit/compute-quota-flags.ts b/packages/lib/server-only/rate-limit/compute-quota-flags.ts
index c53256606..ea463cf69 100644
--- a/packages/lib/server-only/rate-limit/compute-quota-flags.ts
+++ b/packages/lib/server-only/rate-limit/compute-quota-flags.ts
@@ -1,7 +1,12 @@
+import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
+
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
isEmailQuotaExceeded: boolean;
isApiQuotaExceeded: boolean;
+ isDocumentQuotaNearing: boolean;
+ isEmailQuotaNearing: boolean;
+ isApiQuotaNearing: boolean;
};
type ComputeQuotaFlagsOptions = {
@@ -20,11 +25,6 @@ type ComputeQuotaFlagsOptions = {
/**
* 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) {
@@ -38,10 +38,30 @@ const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
return usage >= quota;
};
+/**
+ * A counter is "nearing" its quota once usage reaches the warning threshold
+ * (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
+ * exceeded are mutually exclusive per counter.
+ */
+const isQuotaNearing = (quota: number | null, usage: number): boolean => {
+ if (quota === null || quota === 0) {
+ return false;
+ }
+
+ if (isQuotaExceeded(quota, usage)) {
+ return false;
+ }
+
+ return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
+};
+
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),
+ isDocumentQuotaNearing: isQuotaNearing(quotas.documentQuota, usage?.documentCount ?? 0),
+ isEmailQuotaNearing: isQuotaNearing(quotas.emailQuota, usage?.emailCount ?? 0),
+ isApiQuotaNearing: isQuotaNearing(quotas.apiQuota, usage?.apiCount ?? 0),
};
};
diff --git a/packages/lib/server-only/rate-limit/get-quota-alert-kind.ts b/packages/lib/server-only/rate-limit/get-quota-alert-kind.ts
new file mode 100644
index 000000000..afaaeaa14
--- /dev/null
+++ b/packages/lib/server-only/rate-limit/get-quota-alert-kind.ts
@@ -0,0 +1,40 @@
+export const QUOTA_WARNING_THRESHOLD = 0.8;
+
+export type QuotaAlertKind = 'quota' | 'quotaNearing';
+
+type GetQuotaAlertKindOptions = {
+ previousCount: number;
+ newCount: number;
+ quota: number;
+};
+
+/**
+ * Determines whether the request that moved the counter from `previousCount` to
+ * `newCount` crossed an alert threshold.
+ *
+ * - 'quota': this request reached (or jumped past) the monthly quota.
+ * - 'quotaNearing': this request reached the warning threshold (80% of quota).
+ * - null: no threshold crossed by this request.
+ *
+ * Precondition: callers must handle `quota === null` (unlimited) and `quota === 0`
+ * (blocked) before calling — this function assumes a positive quota.
+ */
+export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKind | null => {
+ const { previousCount, newCount, quota } = opts;
+
+ if (newCount >= quota) {
+ // Only the single request that reached the quota boundary should alert. If the
+ // same request also skipped past the warning threshold, the quota alert
+ // supersedes the warning.
+ return previousCount < quota ? 'quota' : null;
+ }
+
+ // From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
+ // warning threshold equals the quota itself, the warning can never fire — the
+ // exhausting request is handled by the quota branch above.
+ const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
+
+ const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
+
+ return didCrossWarning ? 'quotaNearing' : null;
+};
diff --git a/packages/trpc/server/organisation-router/get-organisation-quota-flags.types.ts b/packages/trpc/server/organisation-router/get-organisation-quota-flags.types.ts
index e7713aeec..3dea662a4 100644
--- a/packages/trpc/server/organisation-router/get-organisation-quota-flags.types.ts
+++ b/packages/trpc/server/organisation-router/get-organisation-quota-flags.types.ts
@@ -12,6 +12,9 @@ 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;