Compare commits

...

8 Commits

Author SHA1 Message Date
Catalin Pit ac5a489966 Merge branch 'main' into fix/improve-admin-org-page 2026-06-25 09:04:34 +03:00
David Nguyen 9c5eb43a26 fix: migrate emails to jobs (#3024) 2026-06-25 13:57:17 +10:00
Konrad e0ef11e8c3 chore(i18n): update Polish translation (#3020) 2026-06-25 13:24:40 +10:00
Catalin Pit ae140b7bc0 refactor: improve quota usage logic and streamline rate limit input handling 2026-06-24 16:16:41 +03:00
Catalin Pit 0587731794 feat: implement quota usage tracking and UI enhancements 2026-06-24 11:15:39 +03:00
Catalin Pit 4996c955cc feat: enhance admin global settings section with inherited settings support 2026-06-23 11:50:48 +03:00
Catalin Pit 90e5926e2f Merge branch 'main' into fix/improve-admin-org-page 2026-06-23 08:28:18 +03:00
Catalin Pit 8403d6cdca fix: admin organisation limits and usage UI 2026-06-22 17:50:49 +03:00
27 changed files with 1524 additions and 837 deletions
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean;
/** When viewing a team, the parent organisation settings the team inherits from. */
inheritedSettings?: OrganisationGlobalSettings | null;
};
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
const notSet = <Trans>Not set</Trans>;
const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
}
return value;
return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
};
const brandingTextValue = (value: string | null | undefined) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value && value.trim() !== '') {
return value;
}
return value;
if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
};
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
if (value !== null && value !== undefined) {
return booleanLabel(value);
}
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
};
const visibilityLabel = (value: string | null | undefined) => {
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
};
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
const label = visibilityLabel(value);
if (label !== null) {
return label;
}
return inheritedValue(visibilityLabel(inherited));
};
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
<DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
@@ -1,11 +1,4 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { FormControl, 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';
@@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input';
/**
* The rate-limit editor renders its own per-row inline errors, but a submit
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
* window). Rendering the field's message here guarantees the form never fails
* silently when those errors are not tied to a row the editor is showing.
*/
type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean;
};
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>;
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
const limitGroups: LimitGroup[] = [
{
title: <Trans>Documents</Trans>,
quotaKey: 'documentQuota',
rateLimitKey: 'documentRateLimits',
},
{
title: <Trans>Emails</Trans>,
quotaKey: 'emailQuota',
rateLimitKey: 'emailRateLimits',
},
{
title: <Trans>API</Trans>,
quotaKey: 'apiQuota',
rateLimitKey: 'apiRateLimits',
},
];
const renderQuotaField = (group: LimitGroup) => (
<FormField
control={control}
name={name(key)}
name={name(group.quotaKey)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
@@ -47,20 +73,18 @@ export const ClaimLimitFields = <T extends FieldValues>({
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
const renderRateLimitField = (key: string, label: ReactNode) => (
const renderRateLimitField = (group: LimitGroup) => (
<FormField
control={control}
name={name(key)}
name={name(group.rateLimitKey)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
);
return (
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
<div className="space-y-3">
<div>
<h3 className="font-semibold text-base">
<Trans>Limits</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
</Trans>
</p>
</div>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
<div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
{limitGroups.map((group) => (
<div key={group.quotaKey} className="space-y-4 p-4">
<h4 className="font-semibold text-sm">{group.title}</h4>
{renderQuotaField(
'emailQuota',
<Trans>Monthly email quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
{renderQuotaField(group)}
{renderRateLimitField(group)}
</div>
))}
</div>
</div>
</div>
);
};
@@ -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,151 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
organisationClaim: OrganisationClaim;
capacityUsage?: CapacityUsage;
};
type UsageCardState = {
status: {
label: ReactNode;
variant: NonNullable<BadgeProps['variant']>;
};
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: <Trans>Unlimited</Trans>, variant: 'neutral' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
}
if (limit === 0) {
return {
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? <Trans>Resource blocked</Trans>,
};
}
if (used > limit) {
return {
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-destructive',
subtext: footnote ?? null,
};
}
if (isQuotaExceeded(limit, used)) {
return {
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
subtext: footnote ?? null,
};
}
if (isQuotaNearing(limit, used)) {
return {
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
subtext: footnote ?? null,
};
}
return {
status: { label: <Trans>Within limit</Trans>, 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 (
<div className="flex flex-col rounded-lg border bg-background p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
<Icon className="h-4 w-4 text-muted-foreground" />
<span>{label}</span>
</div>
{!countOnly && (
<Badge variant={status.variant} size="small">
{status.label}
</Badge>
)}
</div>
<div className="mt-4 flex flex-1 flex-col">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5">
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
{used.toLocaleString()}
</span>
{hasFiniteLimit ? (
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
) : null}
</div>
{hasFiniteLimit ? (
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
) : null}
</div>
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
</div>
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
</div>
);
};
export const OrganisationUsagePanel = ({
organisationId,
monthlyStats,
organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
@@ -30,86 +193,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: <Trans>Members</Trans>,
icon: UsersIcon,
used: capacityUsage.members,
limit: normalizeCapacityLimit(organisationClaim.memberCount),
},
{
key: 'teams',
label: <Trans>Teams</Trans>,
icon: UsersRoundIcon,
used: capacityUsage.teams,
limit: normalizeCapacityLimit(organisationClaim.teamCount),
},
]
: [];
const monthlyRows: UsageRow[] = [
{
counter: 'document' as const,
counter: 'document',
label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email' as const,
counter: 'email',
label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api' as const,
counter: 'api',
label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3>
<div className="mt-4 space-y-6">
{capacityRows.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{capacityRows.map((row) => (
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<Trans>Monthly usage</Trans>
</h3>
{rows.map((row) => {
const percent =
row.effectiveLimit && row.effectiveLimit > 0
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
: 0;
{monthlyStats.length > 0 ? (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
return (
<div key={row.counter} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{row.label}</span>
<span className="text-muted-foreground">
{row.used} /{' '}
{match(row.effectiveLimit)
.with(null, () => <Trans>Unlimited</Trans>)
.with(0, () => <Trans>Blocked</Trans>)
.otherwise(String)}
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{monthlyRows.map((row) => (
<UsageStatCard
key={row.counter}
label={row.label}
icon={row.icon}
used={row.used}
limit={row.effectiveLimit}
action={
selectedStat && isCurrentPeriod ? (
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
) : undefined
}
/>
))}
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div>
)}
</div>
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
<UsageStatCard
label={<Trans>Reports</Trans>}
icon={MailOpenIcon}
used={selectedStat?.emailReports ?? 0}
limit={null}
countOnly
footnote={<Trans>Sent this period</Trans>}
/>
</div>
</div>
</div>
@@ -2,6 +2,7 @@ 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 { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = {
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending}
onClick={() => reset({ organisationId, counter })}
>
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans>
</Button>
);
@@ -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 };
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.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(hasEntryInput);
};
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const entries = value ?? [];
const { t } = useLingui();
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
const window = entry.window.trim();
if (!hasEntryInput(entry)) {
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 (!hasEntryInput(entry)) {
return null;
}
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
};
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(next);
if (index >= value.length) {
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
if (hasEntryInput(nextDraftEntry)) {
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 (
<div className="space-y-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="w-24"
placeholder="5m"
value={entry.window}
disabled={disabled}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="w-32"
type="number"
min={1}
value={entry.max}
disabled={disabled}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span className="w-20 shrink-0">
<Trans>Window</Trans>
</span>
<span className="flex-1">
<Trans>Max requests</Trans>
</span>
<span className="w-9 shrink-0" aria-hidden="true" />
</div>
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
{entries.map((entry, index) => {
const windowError = getWindowError(entry, index);
const maxError = getMaxError(entry);
return (
<div key={index} className="space-y-1">
<div className="flex items-center gap-2">
<Input
className="w-20 shrink-0"
placeholder="5m"
value={entry.window}
disabled={disabled}
aria-invalid={Boolean(windowError)}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="flex-1"
type="number"
min={1}
placeholder="100"
value={entry.max || ''}
disabled={disabled}
aria-invalid={Boolean(maxError)}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
disabled={disabled}
aria-label={t`Remove rate limit`}
onClick={() => removeEntry(index)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-dashed"
disabled={isAddDisabled}
onClick={addEntry}
>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit</Trans>
<Trans>Add rate limit window</Trans>
</Button>
</div>
);
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -30,7 +31,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
@@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
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';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<div className="mt-6 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-sm">
<Trans>Organisation usage</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<SettingsHeader
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<DetailsCard label={<Trans>Members</Trans>}>
<DetailsValue>
{organisation.members.length} /{' '}
{organisation.organisationClaim.memberCount === 0
? t`Unlimited`
: organisation.organisationClaim.memberCount}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Teams</Trans>}>
<DetailsValue>
{organisation.teams.length} /{' '}
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
</DetailsValue>
</DetailsCard>
</div>
<div className="mt-4">
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
/>
</div>
</div>
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
capacityUsage={{
members: organisation.members.length,
teams: organisation.teams.length,
}}
/>
<div className="mt-6 rounded-lg border p-4">
<Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<p className="font-medium text-sm">
<p className="font-semibold text-base">
<Trans>Global Settings</Trans>
</p>
<p className="mt-1 font-normal text-muted-foreground text-sm">
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans>
</p>
</div>
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16"
/>
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<Alert
className={cn(
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
)}
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Subscription</Trans>
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2">
{organisation.subscription ? (
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
<span className="flex items-center gap-2">
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
)}
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
</span>
) : (
<span>
<Trans>No subscription found</Trans>
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<Button
variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline">
<Button variant="outline" className="bg-background">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10">
<div>
<label className="font-medium text-sm leading-none">
<h3 className="font-semibold text-base">
<Trans>Organisation Members</Trans>
</label>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
<div className="my-2">
<div className="mt-3">
<DataTable columns={organisationMembersColumns} data={organisation.members} />
</div>
</div>
<div>
<label className="font-medium text-sm leading-none">
<h3 className="font-semibold text-base">
<Trans>Organisation Teams</Trans>
</label>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
<div className="my-2">
<div className="mt-3">
<DataTable columns={teamsColumns} data={organisation.teams} />
</div>
</div>
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
{field.value ? (
<span className="font-mono text-foreground">{field.value}</span>
) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
</FormItem>
)}
/>
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<FormLabel>
<h3 className="font-semibold text-base">
<Trans>Feature Flags</Trans>
</FormLabel>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
<div className="mt-2 space-y-2 rounded-md border p-4">
<div className="mt-3 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
<AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -1,6 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { sendPendingEmail } from '@documenso/lib/server-only/document/send-pending-email';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -474,9 +473,12 @@ export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise<Execu
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({
id: { type: 'envelopeId', id: envelope.id },
recipientId: recipient.id,
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
// TSP envelopes are forced SEQUENTIAL at send-time; this branch always
+6
View File
@@ -4,11 +4,14 @@ 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_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
import { SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-pending-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';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-removed-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
@@ -44,9 +47,12 @@ export const jobsClient = new JobClient([
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION,
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
@@ -0,0 +1,70 @@
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
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 { getEmailContext } from '../../../server-only/email/get-email-context';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentDeletedEmailsJobDefinition } from './send-document-deleted-emails';
export const run = async ({ payload, io }: { payload: TSendDocumentDeletedEmailsJobDefinition; io: JobRunIO }) => {
const { teamId, documentName, inviterName, inviterEmail, meta, recipients } = payload;
if (recipients.length === 0) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta,
});
// Don't send cancellation emails if the organisation has email sending
// disabled. Re-checked here (not just at enqueue time) because the org can be
// disabled between the delete request and this job running.
if (emailsDisabled) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const i18n = await getI18nInstance(emailLanguage);
for (const recipient of recipients) {
await io.runTask(`send-document-deleted-emails-${recipient.email}`, async () => {
if (!isRecipientEmailValidForSending(recipient)) {
return;
}
const template = createElement(DocumentCancelTemplate, {
documentName,
inviterName: inviterName || undefined,
inviterEmail,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
});
}
};
@@ -0,0 +1,52 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID = 'send.document.deleted.emails';
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
documentName: z.string(),
inviterName: z.string().optional(),
inviterEmail: z.string(),
/**
* The document's email meta (sender, reply-to, language). Captured before the
* envelope is hard-deleted so `getEmailContext` resolves the exact same
* sender/reply-to/language the inline send used.
*/
meta: z
.object({
emailId: z.string().nullable().optional(),
emailReplyTo: z.string().nullable().optional(),
language: z.string().optional(),
})
.nullable(),
recipients: z
.object({
email: z.string(),
name: z.string(),
})
.array(),
});
export type TSendDocumentDeletedEmailsJobDefinition = z.infer<
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION = {
id: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
name: 'Send Document Deleted Emails',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-deleted-emails.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
TSendDocumentDeletedEmailsJobDefinition
>;
@@ -1,27 +1,24 @@
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentPendingEmailJobDefinition } from './send-document-pending-email';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDefinition; io: JobRunIO }) => {
const { envelopeId, recipientId } = payload;
export interface SendPendingEmailOptions {
id: EnvelopeIdOptions;
recipientId: number;
}
export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
...unsafeBuildEnvelopeIdQuery({ type: 'envelopeId', id: envelopeId }, EnvelopeType.DOCUMENT),
recipients: {
some: {
id: recipientId,
@@ -38,12 +35,8 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
},
});
if (!envelope) {
throw new Error('Document not found');
}
if (envelope.recipients.length === 0) {
throw new Error('Document has no recipients');
if (!envelope || envelope.recipients.length === 0) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
@@ -0,0 +1,30 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email';
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSendDocumentPendingEmailJobDefinition = z.infer<typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA>;
export const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION = {
id: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
name: 'Send Document Pending Email',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-pending-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
TSendDocumentPendingEmailJobDefinition
>;
@@ -0,0 +1,105 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
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 { getEmailContext } from '../../../server-only/email/get-email-context';
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientRemovedEmailJobDefinition } from './send-recipient-removed-email';
export const run = async ({ payload, io }: { payload: TSendRecipientRemovedEmailJobDefinition; io: JobRunIO }) => {
const { envelopeId, recipientEmail, recipientName, inviterName } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
documentMeta: true,
},
});
// The envelope may have been deleted between the recipient removal and this
// job running. Treat as a no-op so the job doesn't retry forever.
if (!envelope || !recipientEmail || !isRecipientEmailValidForSending({ email: recipientEmail })) {
return;
}
// Re-checked at send time (not just at enqueue) so the email honors the
// document's current "recipient removed" setting.
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
if (!isRecipientRemovedEmailEnabled) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
return;
}
// Meter the removal email against the organisation email quota/stats.
// Add/remove churn can be used to blast unsolicited removal emails outside
// the email limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
io.logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
envelopeId: envelope.id,
});
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: inviterName || undefined,
assetBaseUrl,
});
const i18n = await getI18nInstance(emailLanguage);
await io.runTask('send-recipient-removed-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await emailTransport.sendMail({
to: {
address: recipientEmail,
name: recipientName,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
});
};
@@ -0,0 +1,34 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.removed.email';
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientEmail: z.string(),
recipientName: z.string(),
inviterName: z.string().optional(),
});
export type TSendRecipientRemovedEmailJobDefinition = z.infer<
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION = {
id: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Recipient Removed Email',
version: '1.0.0',
trigger: {
name: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-recipient-removed-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
TSendRecipientRemovedEmailJobDefinition
>;
@@ -29,7 +29,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -389,7 +388,13 @@ export const completeDocumentWithToken = async ({
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({ id, recipientId: recipient.id });
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
@@ -1,13 +1,9 @@
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
@@ -16,7 +12,6 @@ import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -125,7 +120,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
const { emailLanguage, emailsDisabled } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -192,50 +187,40 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return deletedEnvelope;
}
// Send cancellation emails to recipients.
await Promise.all(
envelope.recipients.map(async (recipient) => {
if (
recipient.sendStatus !== SendStatus.SENT ||
!isRecipientEmailValidForSending(recipient) ||
recipient.role === RecipientRole.CC
) {
return;
}
// Enqueue cancellation emails as a background job. The envelope (and its
// documentMeta) is hard-deleted above, so the job can't look it up later —
// pass a self-contained payload with the recipients to notify.
const recipientsToNotify = envelope.recipients
.filter(
(recipient) =>
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC &&
isRecipientEmailValidForSending(recipient),
)
.map((recipient) => ({
email: recipient.email,
name: recipient.name,
}));
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
if (recipientsToNotify.length > 0) {
await jobs.triggerJob({
name: 'send.document.deleted.emails',
payload: {
teamId: envelope.teamId,
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);
meta: envelope.documentMeta
? {
emailId: envelope.documentMeta.emailId,
emailReplyTo: envelope.documentMeta.emailReplyTo,
language: emailLanguage,
}
: null,
recipients: recipientsToNotify,
},
});
}
return deletedEnvelope;
};
@@ -1,4 +1,4 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
};
};
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
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),
@@ -1,4 +1,4 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
import { getQuotaWarningCount } from '../../universal/quota-usage';
export type QuotaAlertKind = 'quota' | 'quotaNearing';
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// 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 warningCount = getQuotaWarningCount(quota);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
@@ -1,24 +1,16 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export interface DeleteEnvelopeRecipientOptions {
userId: number;
@@ -148,75 +140,16 @@ export const deleteEnvelopeRecipient = async ({
envelope.type === EnvelopeType.DOCUMENT &&
isRecipientEmailValidForSending(recipientToDelete)
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: envelope.team?.name || user.name || undefined,
assetBaseUrl,
});
const {
branding,
emailLanguage,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
return deletedRecipient;
}
// Meter the removal email against the organisation email quota/stats.
// Add/remove churn can be used to blast unsolicited removal emails
// outside the email limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
recipientId: recipientToDelete.id,
// Enqueue the "removed from document" email as a background job so a
// transient mail outage doesn't fail the request and the send is retried.
await jobs.triggerJob({
name: 'send.recipient.removed.email',
payload: {
envelopeId: envelope.id,
});
return deletedRecipient;
}
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
inviterName: envelope.team?.name || user.name || undefined,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}
@@ -1,4 +1,3 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
@@ -7,25 +6,18 @@ import { nanoid } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { createElement } from 'react';
import { isDeepEqual } from 'remeda';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export interface SetDocumentRecipientsOptions {
userId: number;
@@ -88,16 +80,6 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: envelope.documentMeta,
});
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
@@ -290,67 +272,29 @@ export const setDocumentRecipients = async ({
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
// Send emails to deleted recipients who have emails.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
emailsDisabled ||
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientRemovedEmailEnabled ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
if (isRecipientRemovedEmailEnabled) {
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
// Meter against the organisation email quota/stats so add/remove churn
// can't be used to send unsolicited "removed" emails outside the limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
await jobs.triggerJob({
name: 'send.recipient.removed.email',
payload: {
envelopeId: envelope.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
inviterName: user.name || undefined,
},
});
} catch (_err) {
logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
});
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}),
);
}),
);
}
}
// Filter out recipients that have been removed or have been updated.
File diff suppressed because it is too large Load Diff
+33 -8
View File
@@ -6,14 +6,39 @@ import { z } from 'zod';
*
* Example: "5m", "1h", "1d"
*/
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
export const ZRateLimitArraySchema = z.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
);
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: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
});
export const ZRateLimitArraySchema = z
.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
)
.superRefine((entries, ctx) => {
const windows = new Set<string>();
entries.forEach((entry, index) => {
const window = entry.window.trim();
if (windows.has(window)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
path: [index, 'window'],
});
}
windows.add(window);
});
});
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
@@ -52,7 +77,7 @@ export const ZClaimFlagsSchema = z.object({
signingReminders: z.boolean().optional(),
cscQesSigning: z.boolean().optional(),
/**
* Controls whether an organisation is prevented from sending emails.
*
@@ -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);
});
});
+57
View File
@@ -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;
};
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
name: true,
url: true,
ownerUserId: true,
organisationGlobalSettings: true,
},
},
teamEmail: true,
@@ -1,5 +1,6 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true,
url: true,
ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}),
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),