From 0587731794d56d0b3db5e42c49a1bc8f3de239c7 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 24 Jun 2026 11:15:39 +0300 Subject: [PATCH] feat: implement quota usage tracking and UI enhancements --- .../general/organisation-usage-panel.tsx | 295 ++++++++++++++---- .../general/rate-limit-array-input.tsx | 165 ++++++++-- packages/lib/types/subscription.ts | 14 +- packages/lib/universal/quota-usage.test.ts | 99 ++++++ packages/lib/universal/quota-usage.ts | 57 ++++ 5 files changed, 534 insertions(+), 96 deletions(-) create mode 100644 packages/lib/universal/quota-usage.test.ts create mode 100644 packages/lib/universal/quota-usage.ts diff --git a/apps/remix/app/components/general/organisation-usage-panel.tsx b/apps/remix/app/components/general/organisation-usage-panel.tsx index c9ed06265..636839e51 100644 --- a/apps/remix/app/components/general/organisation-usage-panel.tsx +++ b/apps/remix/app/components/general/organisation-usage-panel.tsx @@ -1,13 +1,38 @@ import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period'; +import { + getQuotaUsagePercent, + isQuotaExceeded, + isQuotaNearing, + normalizeCapacityLimit, +} from '@documenso/lib/universal/quota-usage'; +import { cn } from '@documenso/ui/lib/utils'; +import type { BadgeProps } from '@documenso/ui/primitives/badge'; +import { Badge } from '@documenso/ui/primitives/badge'; import { Progress } from '@documenso/ui/primitives/progress'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { Trans } from '@lingui/react/macro'; import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; -import { useState } from 'react'; -import { match } from 'ts-pattern'; +import type { LucideIcon } from 'lucide-react'; +import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useId, useState } from 'react'; + import { OrganisationUsageResetButton } from './organisation-usage-reset-button'; +type CapacityUsage = { + members: number; + teams: number; +}; + +type UsageRow = { + counter: 'document' | 'email' | 'api'; + label: ReactNode; + icon: LucideIcon; + used: number; + effectiveLimit: number | null; +}; + type OrganisationUsagePanelProps = { organisationId: string; monthlyStats: Pick< @@ -15,13 +40,144 @@ type OrganisationUsagePanelProps = { 'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' >[]; organisationClaim: OrganisationClaim; + capacityUsage?: CapacityUsage; +}; + +type UsageCardState = { + status: { + label: ReactNode; + variant: NonNullable; + }; + percent: number; + hasFiniteLimit: boolean; + progressClassName: string; + subtext: ReactNode; +}; + +type UsageCardStateOptions = { + used: number; + limit: number | null | undefined; + footnote?: ReactNode; +}; + +const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => { + const percent = getQuotaUsagePercent(used, limit ?? null); + const hasFiniteLimit = Boolean(limit && limit > 0); + + if (limit === null || limit === undefined) { + return { + status: { label: Unlimited, variant: 'neutral' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? null, + }; + } + + if (limit === 0) { + return { + status: { label: Blocked, variant: 'destructive' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? Resource blocked, + }; + } + + if (isQuotaExceeded(limit, used)) { + return { + status: { + label: used > limit ? Exceeded : Limit reached, + variant: 'destructive', + }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-destructive', + subtext: footnote ?? null, + }; + } + + if (isQuotaNearing(limit, used)) { + return { + status: { label: Near limit, variant: 'warning' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400', + subtext: footnote ?? null, + }; + } + + return { + status: { label: Within limit, variant: 'default' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? null, + }; +}; + +type UsageStatCardProps = { + label: ReactNode; + icon: LucideIcon; + used: number; + limit: number | null | undefined; + /** When true the card is a plain counter with no limit, status or progress. */ + countOnly?: boolean; + footnote?: ReactNode; + action?: ReactNode; +}; + +const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => { + const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote }); + + return ( +
+
+
+ + {label} +
+ + {!countOnly && ( + + {status.label} + + )} +
+ +
+
+
+ + {used.toLocaleString()} + + {hasFiniteLimit ? ( + / {limit?.toLocaleString()} + ) : null} +
+ + {hasFiniteLimit ? ( + {percent}% + ) : null} +
+ + {hasFiniteLimit ? : null} + + {subtext ?

{subtext}

: null} +
+ + {action ?
{action}
: null} +
+ ); }; export const OrganisationUsagePanel = ({ organisationId, monthlyStats, organisationClaim, + capacityUsage, }: OrganisationUsagePanelProps) => { + const monthlyUsagePeriodId = useId(); const [selectedPeriod, setSelectedPeriod] = useState(() => monthlyStats[0]?.period); const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0]; @@ -30,86 +186,105 @@ export const OrganisationUsagePanel = ({ // current period), so only offer the reset action when viewing the current month. const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod(); - const rows = [ + const capacityRows = capacityUsage + ? [ + { + key: 'members', + label: Members, + icon: UsersIcon, + used: capacityUsage.members, + limit: normalizeCapacityLimit(organisationClaim.memberCount), + }, + { + key: 'teams', + label: Teams, + icon: UsersRoundIcon, + used: capacityUsage.teams, + limit: normalizeCapacityLimit(organisationClaim.teamCount), + }, + ] + : []; + + const monthlyRows: UsageRow[] = [ { - counter: 'document' as const, + counter: 'document', label: Documents, + icon: FileIcon, used: selectedStat?.documentCount ?? 0, effectiveLimit: organisationClaim.documentQuota, }, { - counter: 'email' as const, + counter: 'email', label: Emails, + icon: MailIcon, used: selectedStat?.emailCount ?? 0, effectiveLimit: organisationClaim.emailQuota, }, { - counter: 'api' as const, + counter: 'api', label: API requests, + icon: PlugIcon, used: selectedStat?.apiCount ?? 0, effectiveLimit: organisationClaim.apiQuota, }, ]; return ( -
-
-

- Usage for period: {selectedStat?.period || 'N/A'} -

+
+ {capacityRows.length > 0 ? ( +
+ {capacityRows.map((row) => ( + + ))} +
+ ) : null} - {monthlyStats.length > 0 && ( - - )} -
+
+
+

+ Monthly usage +

- {rows.map((row) => { - const percent = - row.effectiveLimit && row.effectiveLimit > 0 - ? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) - : 0; + {monthlyStats.length > 0 ? ( + + ) : null} +
- return ( -
-
- {row.label} - - {row.used} /{' '} - {match(row.effectiveLimit) - .with(null, () => Unlimited) - .with(0, () => Blocked) - .otherwise(String)} - -
+
+ {monthlyRows.map((row) => ( + + ) : undefined + } + /> + ))} - {row.effectiveLimit && row.effectiveLimit > 0 ? : null} - - {selectedStat && isCurrentPeriod && ( -
- -
- )} -
- ); - })} - -
-
- - Reports - - {selectedStat?.emailReports ?? 0} + Reports} + icon={MailOpenIcon} + used={selectedStat?.emailReports ?? 0} + limit={null} + countOnly + footnote={Sent this period} + />
diff --git a/apps/remix/app/components/general/rate-limit-array-input.tsx b/apps/remix/app/components/general/rate-limit-array-input.tsx index a2adaad38..a631b7b01 100644 --- a/apps/remix/app/components/general/rate-limit-array-input.tsx +++ b/apps/remix/app/components/general/rate-limit-array-input.tsx @@ -1,7 +1,9 @@ +import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; -import { Trans } from '@lingui/react/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { useState } from 'react'; type RateLimitEntryValue = { window: string; max: number }; @@ -11,50 +13,153 @@ type RateLimitArrayInputProps = { disabled?: boolean; }; +const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 }; + +/** Keep in-progress rows; drop rows that are completely empty. */ +const persistEntries = (entries: RateLimitEntryValue[]) => { + return entries + .map((entry) => ({ ...entry, window: entry.window.trim() })) + .filter((entry) => entry.window !== '' || entry.max > 0); +}; + export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => { - const entries = value ?? []; + const { t } = useLingui(); + const [draftEntry, setDraftEntry] = useState(null); + + const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY]; + + const getWindowError = (entry: RateLimitEntryValue, index: number) => { + const window = entry.window.trim(); + + if (window === '' && entry.max <= 0) { + return null; + } + + if (window === '') { + return t`Enter a window, e.g. 5m`; + } + + if (!RATE_LIMIT_WINDOW_REGEX.test(window)) { + return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`; + } + + const isDuplicateWindow = entries.some((otherEntry, otherIndex) => { + return otherIndex !== index && otherEntry.window.trim() === window; + }); + + return isDuplicateWindow ? t`Use a unique window for each rate limit` : null; + }; + + const getMaxError = (entry: RateLimitEntryValue) => { + if (entry.window.trim() === '' && entry.max <= 0) { + return null; + } + + return entry.max > 0 ? null : t`Enter a max request count greater than 0`; + }; const updateEntry = (index: number, patch: Partial) => { - const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); - onChange(next); + if (index >= value.length) { + const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch }; + const shouldPersistDraft = nextDraftEntry.window.trim() !== '' || nextDraftEntry.max > 0; + + if (shouldPersistDraft) { + onChange(persistEntries([...value, nextDraftEntry])); + setDraftEntry(null); + return; + } + + setDraftEntry(nextDraftEntry); + return; + } + + const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); + onChange(persistEntries(next)); }; const removeEntry = (index: number) => { - onChange(entries.filter((_, i) => i !== index)); + if (index >= value.length) { + setDraftEntry(null); + return; + } + + const next = value.filter((_, i) => i !== index); + onChange(persistEntries(next)); }; const addEntry = () => { - onChange([...entries, { window: '5m', max: 100 }]); + setDraftEntry(EMPTY_ENTRY); }; + const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry)); + const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors; + return (
- {entries.map((entry, index) => ( -
- updateEntry(index, { window: e.target.value })} - /> - updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })} - /> - -
- ))} +
+ + Window + + + Max requests + +
- +
+ + {windowError ?

{windowError}

: null} + {maxError ?

{maxError}

: null} +
+ ); + })} + +
); diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index e9c04c643..48daddbcb 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -8,8 +8,11 @@ import { z } from 'zod'; */ export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/; +const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h'; +const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit'; + export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, { - message: 'Use a duration with a unit, e.g. 5m, 1h, or 24h', + message: RATE_LIMIT_WINDOW_ERROR_MESSAGE, }); export const ZRateLimitArraySchema = z @@ -20,21 +23,20 @@ export const ZRateLimitArraySchema = z }), ) .superRefine((entries, ctx) => { - const windows = new Map(); + const windows = new Set(); entries.forEach((entry, index) => { const window = entry.window.trim(); - const duplicateIndex = windows.get(window); - if (duplicateIndex !== undefined) { + if (windows.has(window)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Use a unique window for each rate limit', + message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE, path: [index, 'window'], }); } - windows.set(window, index); + windows.add(window); }); }); diff --git a/packages/lib/universal/quota-usage.test.ts b/packages/lib/universal/quota-usage.test.ts new file mode 100644 index 000000000..4a992bbdb --- /dev/null +++ b/packages/lib/universal/quota-usage.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + getQuotaUsagePercent, + getQuotaWarningCount, + isQuotaExceeded, + isQuotaNearing, + normalizeCapacityLimit, +} from './quota-usage'; + +describe('isQuotaExceeded', () => { + it('treats null quota as unlimited (never exceeded)', () => { + expect(isQuotaExceeded(null, 0)).toBe(false); + expect(isQuotaExceeded(null, 1_000_000)).toBe(false); + }); + + it('treats a zero quota as blocked (always exceeded)', () => { + expect(isQuotaExceeded(0, 0)).toBe(true); + expect(isQuotaExceeded(0, 5)).toBe(true); + }); + + it('is exceeded once usage reaches the quota (>= boundary)', () => { + expect(isQuotaExceeded(10, 9)).toBe(false); + expect(isQuotaExceeded(10, 10)).toBe(true); + expect(isQuotaExceeded(10, 11)).toBe(true); + }); +}); + +describe('getQuotaWarningCount', () => { + it('rounds the 80% threshold up', () => { + expect(getQuotaWarningCount(10)).toBe(8); + expect(getQuotaWarningCount(100)).toBe(80); + // 5 * 0.8 = 4 exactly. + expect(getQuotaWarningCount(5)).toBe(4); + // 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself. + expect(getQuotaWarningCount(3)).toBe(3); + }); +}); + +describe('isQuotaNearing', () => { + it('is never nearing for unlimited or blocked quotas', () => { + expect(isQuotaNearing(null, 5)).toBe(false); + expect(isQuotaNearing(0, 5)).toBe(false); + }); + + it('is nearing from the warning threshold up to (but not including) the quota', () => { + expect(isQuotaNearing(10, 7)).toBe(false); + expect(isQuotaNearing(10, 8)).toBe(true); + expect(isQuotaNearing(10, 9)).toBe(true); + }); + + it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => { + expect(isQuotaNearing(10, 10)).toBe(false); + expect(isQuotaNearing(10, 11)).toBe(false); + }); + + it('can never fire for tiny quotas where the warning count equals the quota', () => { + // getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded. + expect(isQuotaNearing(3, 2)).toBe(false); + expect(isQuotaNearing(3, 3)).toBe(false); + }); + + it('agrees with the warning-count helper at the boundary', () => { + const quota = 250; + const warningCount = getQuotaWarningCount(quota); + + expect(isQuotaNearing(quota, warningCount - 1)).toBe(false); + expect(isQuotaNearing(quota, warningCount)).toBe(true); + }); +}); + +describe('getQuotaUsagePercent', () => { + it('returns 0 for unlimited or non-positive quotas', () => { + expect(getQuotaUsagePercent(5, null)).toBe(0); + expect(getQuotaUsagePercent(5, 0)).toBe(0); + expect(getQuotaUsagePercent(5, -10)).toBe(0); + }); + + it('rounds the percentage to the nearest integer', () => { + expect(getQuotaUsagePercent(1, 3)).toBe(33); + expect(getQuotaUsagePercent(2, 3)).toBe(67); + expect(getQuotaUsagePercent(50, 100)).toBe(50); + }); + + it('clamps the percentage to 100 when usage exceeds the quota', () => { + expect(getQuotaUsagePercent(150, 100)).toBe(100); + }); +}); + +describe('normalizeCapacityLimit', () => { + it('maps 0 (unlimited for capacity limits) to null', () => { + expect(normalizeCapacityLimit(0)).toBeNull(); + }); + + it('passes positive limits through unchanged', () => { + expect(normalizeCapacityLimit(1)).toBe(1); + expect(normalizeCapacityLimit(25)).toBe(25); + }); +}); diff --git a/packages/lib/universal/quota-usage.ts b/packages/lib/universal/quota-usage.ts new file mode 100644 index 000000000..fc2c23449 --- /dev/null +++ b/packages/lib/universal/quota-usage.ts @@ -0,0 +1,57 @@ +export const QUOTA_WARNING_THRESHOLD = 0.8; + +/** + * Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded. + */ +export const isQuotaExceeded = (quota: number | null, usage: number): boolean => { + if (quota === null) { + return false; + } + + if (quota === 0) { + return true; + } + + return usage >= quota; +}; + +/** + * The usage count at which a positive quota starts "nearing" (80% rounded up). + * The single source for the warning threshold math so the UI panel, quota flags, + * and the per-request alert path can't drift apart. + */ +export const getQuotaWarningCount = (quota: number): number => { + return Math.ceil(quota * QUOTA_WARNING_THRESHOLD); +}; + +/** + * Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded. + */ +export const isQuotaNearing = (quota: number | null, usage: number): boolean => { + if (quota === null || quota === 0) { + return false; + } + + if (isQuotaExceeded(quota, usage)) { + return false; + } + + return usage >= getQuotaWarningCount(quota); +}; + +export const getQuotaUsagePercent = (usage: number, quota: number | null): number => { + if (quota === null || quota <= 0) { + return 0; + } + + return Math.min(100, Math.round((usage / quota) * 100)); +}; + +/** Member/team capacity limits use `0` for unlimited. */ +export const normalizeCapacityLimit = (limit: number): number | null => { + if (limit === 0) { + return null; + } + + return limit; +};