mirror of
https://github.com/documenso/documenso.git
synced 2026-07-22 16:03:39 +10:00
Merge branch 'main' into fix/cc-recipient-order-last
This commit is contained in:
@@ -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