From 61138cdd812f36d553a99871c2a8babf073a0627 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 31 May 2026 00:34:28 +1000 Subject: [PATCH 01/68] fix: add dynamic rate limits (#2892) --- .vscode/settings.json | 2 +- .../forms/subscription-claim-form.tsx | 35 + .../components/general/claim-limit-fields.tsx | 97 ++ .../general/organisation-usage-panel.tsx | 80 ++ .../organisation-usage-reset-button.tsx | 38 + .../general/rate-limit-array-input.tsx | 61 ++ .../admin+/organisations.$id.tsx | 52 + packages/api/v1/middleware/authenticated.ts | 15 +- .../api/v1/organisation-rate-limits.spec.ts | 709 ++++++++++++++ .../api/v2/organisation-rate-limits.spec.ts | 909 ++++++++++++++++++ .../e2e/api/v2/recipient-count-limit.spec.ts | 290 ++++++ packages/ee/server-only/limits/constants.ts | 7 + .../stripe/webhook/on-subscription-deleted.ts | 8 +- .../templates/organisation-limit-exceeded.tsx | 115 +++ packages/lib/jobs/client.ts | 2 + ...ganisation-limit-exceeded-email.handler.ts | 86 ++ .../send-organisation-limit-exceeded-email.ts | 34 + .../emails/send-signing-email.handler.ts | 19 +- .../server-only/document/resend-document.ts | 45 +- .../lib/server-only/document/send-document.ts | 23 + .../email/assert-org-email-send-allowed.ts | 42 - .../create-embedding-presign-token.ts | 2 +- .../server-only/envelope/create-envelope.ts | 12 + .../envelope/duplicate-envelope.ts | 12 +- .../organisation/create-organisation.ts | 20 +- .../public-api/get-api-token-by-token.ts | 31 +- .../assert-organisation-rates-and-limits.ts | 89 ++ .../rate-limit/check-monthly-quota.ts | 95 ++ .../check-organisation-rate-limits.ts | 48 + .../rate-limit/current-monthly-period.ts | 7 + .../lib/server-only/rate-limit/rate-limit.ts | 11 +- .../lib/server-only/rate-limit/rate-limits.ts | 14 - packages/lib/server-only/rate-limit/types.ts | 17 + .../subscription/get-subscription-claim.ts | 27 + .../create-document-from-direct-template.ts | 37 + .../template/create-document-from-template.ts | 8 + .../webhooks/zapier/validateApiToken.ts | 2 +- packages/lib/types/subscription.ts | 69 ++ packages/lib/universal/id.ts | 1 + packages/lib/utils/organisations-claims.ts | 13 +- .../migration.sql | 52 + packages/prisma/schema.prisma | 45 +- .../admin-router/create-admin-organisation.ts | 8 +- .../admin-router/create-subscription-claim.ts | 22 +- .../create-subscription-claim.types.ts | 12 +- .../find-subscription-claims.types.ts | 7 + .../admin-router/get-admin-organisation.ts | 9 +- .../get-admin-organisation.types.ts | 9 + .../reset-organisation-monthly-stat.ts | 32 + .../reset-organisation-monthly-stat.types.ts | 10 + packages/trpc/server/admin-router/router.ts | 2 + .../swap-organisation-subscription.ts | 8 +- .../update-admin-organisation.types.ts | 7 + .../create-organisation.ts | 8 +- 54 files changed, 3306 insertions(+), 109 deletions(-) create mode 100644 apps/remix/app/components/general/claim-limit-fields.tsx create mode 100644 apps/remix/app/components/general/organisation-usage-panel.tsx create mode 100644 apps/remix/app/components/general/organisation-usage-reset-button.tsx create mode 100644 apps/remix/app/components/general/rate-limit-array-input.tsx create mode 100644 packages/app-tests/e2e/api/v1/organisation-rate-limits.spec.ts create mode 100644 packages/app-tests/e2e/api/v2/organisation-rate-limits.spec.ts create mode 100644 packages/app-tests/e2e/api/v2/recipient-count-limit.spec.ts create mode 100644 packages/email/templates/organisation-limit-exceeded.tsx create mode 100644 packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.ts delete mode 100644 packages/lib/server-only/email/assert-org-email-send-allowed.ts create mode 100644 packages/lib/server-only/rate-limit/assert-organisation-rates-and-limits.ts create mode 100644 packages/lib/server-only/rate-limit/check-monthly-quota.ts create mode 100644 packages/lib/server-only/rate-limit/check-organisation-rate-limits.ts create mode 100644 packages/lib/server-only/rate-limit/current-monthly-period.ts create mode 100644 packages/lib/server-only/rate-limit/types.ts create mode 100644 packages/lib/server-only/subscription/get-subscription-claim.ts create mode 100644 packages/prisma/migrations/20260529132406_add_org_rate_limits/migration.sql create mode 100644 packages/trpc/server/admin-router/reset-organisation-monthly-stat.ts create mode 100644 packages/trpc/server/admin-router/reset-organisation-monthly-stat.types.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 7ef2f1a5a..15c6dc877 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,6 +29,6 @@ "editor.defaultFormatter": "biomejs.biome" }, "[json]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.json-language-features" } } diff --git a/apps/remix/app/components/forms/subscription-claim-form.tsx b/apps/remix/app/components/forms/subscription-claim-form.tsx index dc5956fab..e3cef8d92 100644 --- a/apps/remix/app/components/forms/subscription-claim-form.tsx +++ b/apps/remix/app/components/forms/subscription-claim-form.tsx @@ -20,6 +20,8 @@ import { useForm } from 'react-hook-form'; import { Link } from 'react-router'; import type { z } from 'zod'; +import { ClaimLimitFields } from '../general/claim-limit-fields'; + export type SubscriptionClaimFormValues = z.infer; type SubscriptionClaimFormProps = { @@ -49,7 +51,14 @@ export const SubscriptionClaimForm = ({ teamCount: subscriptionClaim.teamCount, memberCount: subscriptionClaim.memberCount, envelopeItemCount: subscriptionClaim.envelopeItemCount, + recipientCount: subscriptionClaim.recipientCount, flags: subscriptionClaim.flags, + documentRateLimits: subscriptionClaim.documentRateLimits, + documentQuota: subscriptionClaim.documentQuota, + emailRateLimits: subscriptionClaim.emailRateLimits, + emailQuota: subscriptionClaim.emailQuota, + apiRateLimits: subscriptionClaim.apiRateLimits, + apiQuota: subscriptionClaim.apiQuota, }, }); @@ -145,6 +154,30 @@ export const SubscriptionClaimForm = ({ )} /> + ( + + + Recipient Count + + + field.onChange(parseInt(e.target.value, 10) || 0)} + /> + + + Maximum number of recipients per document allowed. 0 = Unlimited + + + + )} + /> +
Feature Flags @@ -203,6 +236,8 @@ export const SubscriptionClaimForm = ({ )}
+ + {formSubmitTrigger} diff --git a/apps/remix/app/components/general/claim-limit-fields.tsx b/apps/remix/app/components/general/claim-limit-fields.tsx new file mode 100644 index 000000000..ed29c8fc3 --- /dev/null +++ b/apps/remix/app/components/general/claim-limit-fields.tsx @@ -0,0 +1,97 @@ +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Trans, useLingui } from '@lingui/react/macro'; +import type { ReactNode } from 'react'; +import type { Control, FieldValues, Path } from 'react-hook-form'; + +import { RateLimitArrayInput } from './rate-limit-array-input'; + +type ClaimLimitFieldsProps = { + control: Control; + /** e.g. '' for the claim form, 'claims.' for the org admin form. */ + prefix?: string; + disabled?: boolean; +}; + +export const ClaimLimitFields = ({ + control, + prefix = '', + disabled, +}: ClaimLimitFieldsProps) => { + const { t } = useLingui(); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const name = (key: string) => `${prefix}${key}` as Path; + + const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => ( + ( + + {label} + + field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))} + /> + + {description} + + + )} + /> + ); + + const renderRateLimitField = (key: string, label: ReactNode) => ( + ( + + {label} + + + + + + )} + /> + ); + + return ( +
+ + Limits + + + {renderQuotaField( + 'documentQuota', + Monthly document quota, + Empty = Unlimited, 0 = Blocked, + )} + {renderRateLimitField('documentRateLimits', Document rate limits)} + + {renderQuotaField( + 'emailQuota', + Monthly email quota, + Empty = Unlimited, 0 = Blocked, + )} + {renderRateLimitField('emailRateLimits', Email rate limits)} + + {renderQuotaField('apiQuota', Monthly API quota, Empty = Unlimited, 0 = Blocked)} + {renderRateLimitField('apiRateLimits', API rate limits)} +
+ ); +}; diff --git a/apps/remix/app/components/general/organisation-usage-panel.tsx b/apps/remix/app/components/general/organisation-usage-panel.tsx new file mode 100644 index 000000000..7936e539f --- /dev/null +++ b/apps/remix/app/components/general/organisation-usage-panel.tsx @@ -0,0 +1,80 @@ +import { Progress } from '@documenso/ui/primitives/progress'; + +import { Trans } from '@lingui/react/macro'; +import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; +import { match } from 'ts-pattern'; +import { OrganisationUsageResetButton } from './organisation-usage-reset-button'; + +type OrganisationUsagePanelProps = { + organisationId: string; + monthlyStats: Pick[]; + organisationClaim: OrganisationClaim; +}; + +export const OrganisationUsagePanel = ({ + organisationId, + monthlyStats, + organisationClaim, +}: OrganisationUsagePanelProps) => { + const rows = [ + { + counter: 'document' as const, + label: Documents, + used: monthlyStats[0]?.documentCount ?? 0, + effectiveLimit: organisationClaim.documentQuota, + }, + { + counter: 'email' as const, + label: Emails, + used: monthlyStats[0]?.emailCount ?? 0, + effectiveLimit: organisationClaim.emailQuota, + }, + { + counter: 'api' as const, + label: API requests, + used: monthlyStats[0]?.apiCount ?? 0, + effectiveLimit: organisationClaim.apiQuota, + }, + ]; + + // Todo: This may not show if the organisation has no usage data for the current month. + return ( +
+
+

+ Usage for period: {monthlyStats[0]?.period || 'N/A'} +

+
+ + {rows.map((row) => { + const percent = + row.effectiveLimit && row.effectiveLimit > 0 + ? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) + : 0; + + return ( +
+
+ {row.label} + + {row.used} /{' '} + {match(row.effectiveLimit) + .with(null, () => Unlimited) + .with(0, () => Blocked) + .otherwise(String)} + +
+ + {row.effectiveLimit && row.effectiveLimit > 0 ? : null} + + {monthlyStats[0] && ( +
+ +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/apps/remix/app/components/general/organisation-usage-reset-button.tsx b/apps/remix/app/components/general/organisation-usage-reset-button.tsx new file mode 100644 index 000000000..73340b122 --- /dev/null +++ b/apps/remix/app/components/general/organisation-usage-reset-button.tsx @@ -0,0 +1,38 @@ +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useRevalidator } from 'react-router'; + +type OrganisationUsageResetButtonProps = { + organisationId: string; + counter: 'document' | 'email' | 'api'; +}; + +export const OrganisationUsageResetButton = ({ organisationId, counter }: OrganisationUsageResetButtonProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + const { revalidate } = useRevalidator(); + + const { mutateAsync: reset, isPending } = trpc.admin.organisation.resetMonthlyStat.useMutation({ + onSuccess: async () => { + toast({ title: t`Counter reset.` }); + await revalidate(); + }, + onError: () => { + toast({ title: t`Failed to reset counter.`, variant: 'destructive' }); + }, + }); + + return ( + + ); +}; diff --git a/apps/remix/app/components/general/rate-limit-array-input.tsx b/apps/remix/app/components/general/rate-limit-array-input.tsx new file mode 100644 index 000000000..a2adaad38 --- /dev/null +++ b/apps/remix/app/components/general/rate-limit-array-input.tsx @@ -0,0 +1,61 @@ +import { Button } from '@documenso/ui/primitives/button'; +import { Input } from '@documenso/ui/primitives/input'; +import { Trans } from '@lingui/react/macro'; +import { PlusIcon, Trash2Icon } from 'lucide-react'; + +type RateLimitEntryValue = { window: string; max: number }; + +type RateLimitArrayInputProps = { + value: RateLimitEntryValue[]; + onChange: (value: RateLimitEntryValue[]) => void; + disabled?: boolean; +}; + +export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => { + const entries = value ?? []; + + const updateEntry = (index: number, patch: Partial) => { + const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); + onChange(next); + }; + + const removeEntry = (index: number) => { + onChange(entries.filter((_, i) => i !== index)); + }; + + const addEntry = () => { + onChange([...entries, { window: '5m', max: 100 }]); + }; + + return ( +
+ {entries.map((entry, index) => ( +
+ updateEntry(index, { window: e.target.value })} + /> + updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })} + /> + +
+ ))} + + +
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 64a6839f8..a0c5cf37a 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -42,7 +42,9 @@ import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin- import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section'; +import { ClaimLimitFields } from '~/components/general/claim-limit-fields'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; +import { OrganisationUsagePanel } from '~/components/general/organisation-usage-panel'; import { SettingsHeader } from '~/components/general/settings-header'; import type { Route } from './+types/organisations.$id'; @@ -293,6 +295,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro + +
+ +
@@ -565,7 +575,23 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin teamCount: organisation.organisationClaim.teamCount, memberCount: organisation.organisationClaim.memberCount, envelopeItemCount: organisation.organisationClaim.envelopeItemCount, + recipientCount: organisation.organisationClaim.recipientCount, flags: organisation.organisationClaim.flags, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + documentRateLimits: organisation.organisationClaim.documentRateLimits as NonNullable< + TUpdateOrganisationBillingFormSchema['claims'] + >['documentRateLimits'], + documentQuota: organisation.organisationClaim.documentQuota, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + emailRateLimits: organisation.organisationClaim.emailRateLimits as NonNullable< + TUpdateOrganisationBillingFormSchema['claims'] + >['emailRateLimits'], + emailQuota: organisation.organisationClaim.emailQuota, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + apiRateLimits: organisation.organisationClaim.apiRateLimits as NonNullable< + TUpdateOrganisationBillingFormSchema['claims'] + >['apiRateLimits'], + apiQuota: organisation.organisationClaim.apiQuota, }, originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '', }, @@ -745,6 +771,30 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin )} /> + ( + + + Recipient Count + + + field.onChange(parseInt(e.target.value, 10) || 0)} + /> + + + Maximum number of recipients per document allowed. 0 = Unlimited + + + + )} + /> +
Feature Flags @@ -803,6 +853,8 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin )}
+ +
+ ); + + return [ + { + header: t`Organisation`, + accessorKey: 'organisationName', + cell: ({ row }) => ( + + {row.original.organisationName} + + ), + }, + { + header: t`Claim`, + accessorKey: 'originalClaimId', + cell: ({ row }) => {row.original.originalClaimId ?? '—'}, + }, + { + header: t`Period`, + accessorKey: 'period', + cell: ({ row }) => row.original.period, + }, + { + header: () => sortableHeader(t`Documents`, 'documentCount'), + accessorKey: 'documentCount', + cell: ({ row }) => row.original.documentCount, + }, + { + header: () => sortableHeader(t`Emails`, 'emailCount'), + accessorKey: 'emailCount', + cell: ({ row }) => row.original.emailCount, + }, + { + header: () => sortableHeader(t`API`, 'apiCount'), + accessorKey: 'apiCount', + cell: ({ row }) => row.original.apiCount, + }, + { + header: () => sortableHeader(t`Total`, 'totalCount'), + accessorKey: 'totalCount', + cell: ({ row }) => {row.original.totalCount}, + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t, orderByColumn, orderByDirection]); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + ), + }} + > + {(table) => } + +
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index af24ab0bf..af00d92cb 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -10,6 +10,7 @@ import { BarChart3, Building2Icon, FileStack, + LineChartIcon, MailIcon, Settings, Trophy, @@ -153,6 +154,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) { + + + )} + + + + + + Sync Stripe subscription + + + + Fetch the latest subscription data from Stripe and apply it to this organisation. + + + +
+ +
+ ( + + + + + + + + )} + /> + + + + + + +
+
+ +
+ + ); +}; diff --git a/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx b/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx index 72c0b3326..90489bf8f 100644 --- a/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx @@ -67,7 +67,7 @@ export const AdminSwapSubscriptionDialog = ({ const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId); - const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation(); + const { mutateAsync: swapSubscription } = trpc.admin.organisation.subscription.swap.useMutation(); const onSubmit = async () => { if (!selectedOrgId) { diff --git a/apps/remix/app/components/general/organisation-usage-reset-button.tsx b/apps/remix/app/components/general/organisation-usage-reset-button.tsx index 73340b122..7451f6e1c 100644 --- a/apps/remix/app/components/general/organisation-usage-reset-button.tsx +++ b/apps/remix/app/components/general/organisation-usage-reset-button.tsx @@ -14,7 +14,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi const { toast } = useToast(); const { revalidate } = useRevalidator(); - const { mutateAsync: reset, isPending } = trpc.admin.organisation.resetMonthlyStat.useMutation({ + const { mutateAsync: reset, isPending } = trpc.admin.organisation.stats.reset.useMutation({ onSuccess: async () => { toast({ title: t`Counter reset.` }); await revalidate(); diff --git a/apps/remix/app/components/tables/admin-organisation-stats-table.tsx b/apps/remix/app/components/tables/admin-organisation-stats-table.tsx index 97275e1c1..0e8bf7003 100644 --- a/apps/remix/app/components/tables/admin-organisation-stats-table.tsx +++ b/apps/remix/app/components/tables/admin-organisation-stats-table.tsx @@ -41,7 +41,7 @@ export const AdminOrganisationStatsTable = () => { const orderByColumn = parseOrderByColumn(searchParams?.get('orderByColumn') ?? undefined); const orderByDirection = parseOrderByDirection(searchParams?.get('orderByDirection') ?? undefined); - const { data, isLoading, isLoadingError } = trpc.admin.organisation.findStats.useQuery({ + const { data, isLoading, isLoadingError } = trpc.admin.organisation.stats.find.useQuery({ query: parsedSearchParams.query, page: parsedSearchParams.page, perPage: parsedSearchParams.perPage, diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index a0c5cf37a..9824390d1 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -40,6 +40,7 @@ import type { z } from 'zod'; import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog'; import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog'; import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; +import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section'; import { ClaimLimitFields } from '~/components/general/claim-limit-fields'; @@ -377,7 +378,16 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro )} {organisation.subscription && ( -
+
+ + Sync Stripe subscription + + } + /> +