mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: add early quota warning (#2986)
## Description - Add banners when plans near fair use limits - Automated email alerts to our support when nearing fair use limits
This commit is contained in:
@@ -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,
|
||||
|
||||
+50
-22
@@ -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}
|
||||
`,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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.',
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user