mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: add dynamic rate limits (#2892)
This commit is contained in:
Vendored
+1
-1
@@ -29,6 +29,6 @@
|
|||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { ClaimLimitFields } from '../general/claim-limit-fields';
|
||||||
|
|
||||||
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||||
|
|
||||||
type SubscriptionClaimFormProps = {
|
type SubscriptionClaimFormProps = {
|
||||||
@@ -49,7 +51,14 @@ export const SubscriptionClaimForm = ({
|
|||||||
teamCount: subscriptionClaim.teamCount,
|
teamCount: subscriptionClaim.teamCount,
|
||||||
memberCount: subscriptionClaim.memberCount,
|
memberCount: subscriptionClaim.memberCount,
|
||||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||||
|
recipientCount: subscriptionClaim.recipientCount,
|
||||||
flags: subscriptionClaim.flags,
|
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 = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="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>
|
<FormLabel>
|
||||||
<Trans>Feature Flags</Trans>
|
<Trans>Feature Flags</Trans>
|
||||||
@@ -203,6 +236,8 @@ export const SubscriptionClaimForm = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ClaimLimitFields control={form.control} disabled={form.formState.isSubmitting} />
|
||||||
|
|
||||||
{formSubmitTrigger}
|
{formSubmitTrigger}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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<T extends FieldValues> = {
|
||||||
|
control: Control<T>;
|
||||||
|
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
||||||
|
prefix?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClaimLimitFields = <T extends FieldValues>({
|
||||||
|
control,
|
||||||
|
prefix = '',
|
||||||
|
disabled,
|
||||||
|
}: ClaimLimitFieldsProps<T>) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
// 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) => (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name(key)}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
disabled={disabled}
|
||||||
|
value={field.value === null || field.value === undefined ? '' : field.value}
|
||||||
|
placeholder={t`Unlimited`}
|
||||||
|
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) => (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={name(key)}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Limits</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
{renderQuotaField(
|
||||||
|
'documentQuota',
|
||||||
|
<Trans>Monthly document quota</Trans>,
|
||||||
|
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||||
|
)}
|
||||||
|
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
||||||
|
|
||||||
|
{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>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<OrganisationMonthlyStat, 'period' | 'documentCount' | 'emailCount' | 'apiCount'>[];
|
||||||
|
organisationClaim: OrganisationClaim;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationUsagePanel = ({
|
||||||
|
organisationId,
|
||||||
|
monthlyStats,
|
||||||
|
organisationClaim,
|
||||||
|
}: OrganisationUsagePanelProps) => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
counter: 'document' as const,
|
||||||
|
label: <Trans>Documents</Trans>,
|
||||||
|
used: monthlyStats[0]?.documentCount ?? 0,
|
||||||
|
effectiveLimit: organisationClaim.documentQuota,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
counter: 'email' as const,
|
||||||
|
label: <Trans>Emails</Trans>,
|
||||||
|
used: monthlyStats[0]?.emailCount ?? 0,
|
||||||
|
effectiveLimit: organisationClaim.emailQuota,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
counter: 'api' as const,
|
||||||
|
label: <Trans>API requests</Trans>,
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-sm">
|
||||||
|
<Trans>Usage for period: {monthlyStats[0]?.period || 'N/A'}</Trans>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.map((row) => {
|
||||||
|
const percent =
|
||||||
|
row.effectiveLimit && row.effectiveLimit > 0
|
||||||
|
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
||||||
|
|
||||||
|
{monthlyStats[0] && (
|
||||||
|
<div className="flex w-full justify-end pt-1">
|
||||||
|
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => reset({ organisationId, counter })}
|
||||||
|
>
|
||||||
|
<Trans>Reset</Trans>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<RateLimitEntryValue>) => {
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Add rate limit</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -42,7 +42,9 @@ import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-
|
|||||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||||
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
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 { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
|
import { OrganisationUsagePanel } from '~/components/general/organisation-usage-panel';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
|
||||||
import type { Route } from './+types/organisations.$id';
|
import type { Route } from './+types/organisations.$id';
|
||||||
@@ -293,6 +295,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
</DetailsValue>
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<OrganisationUsagePanel
|
||||||
|
organisationId={organisation.id}
|
||||||
|
monthlyStats={organisation.monthlyStats}
|
||||||
|
organisationClaim={organisation.organisationClaim}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg border p-4">
|
<div className="mt-6 rounded-lg border p-4">
|
||||||
@@ -565,7 +575,23 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
teamCount: organisation.organisationClaim.teamCount,
|
teamCount: organisation.organisationClaim.teamCount,
|
||||||
memberCount: organisation.organisationClaim.memberCount,
|
memberCount: organisation.organisationClaim.memberCount,
|
||||||
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
|
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
|
||||||
|
recipientCount: organisation.organisationClaim.recipientCount,
|
||||||
flags: organisation.organisationClaim.flags,
|
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 || '',
|
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
|
||||||
},
|
},
|
||||||
@@ -745,6 +771,30 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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>
|
<FormLabel>
|
||||||
<Trans>Feature Flags</Trans>
|
<Trans>Feature Flags</Trans>
|
||||||
@@ -803,6 +853,8 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ClaimLimitFields control={form.control} prefix="claims." />
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Update</Trans>
|
<Trans>Update</Trans>
|
||||||
|
|||||||
@@ -95,13 +95,22 @@ export const authenticatedMiddleware = <
|
|||||||
{ metadata, logger: apiLogger },
|
{ metadata, logger: apiLogger },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log({ err });
|
apiLogger.info({
|
||||||
|
...infoToLog,
|
||||||
apiLogger.info(infoToLog);
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
let message = 'Unauthorized';
|
let message = 'Unauthorized';
|
||||||
|
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
|
if (err.code === AppErrorCode.TOO_MANY_REQUESTS) {
|
||||||
|
return {
|
||||||
|
status: 429,
|
||||||
|
body: { message: err.message },
|
||||||
|
headers: err.headers,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
message = err.message;
|
message = err.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,709 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||||
|
import type { Organisation, Team, User } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic organisation rate-limit & quota tests — API **v1** edition.
|
||||||
|
*
|
||||||
|
* This is the v1 counterpart to `../v2/organisation-rate-limits.spec.ts`. It
|
||||||
|
* covers the feature added in `feat: add dynamic rate limits`:
|
||||||
|
* - Three counters: `api`, `document`, `email`.
|
||||||
|
* - Two enforcement stages per counter:
|
||||||
|
* 1. Windowed rate limits (`*RateLimits`) — a 429 distinguished by its
|
||||||
|
* message. NOTE: in v2 this 429 also carries `X-RateLimit-*` headers, but
|
||||||
|
* v1 does NOT surface them (the ts-rest handler drops the headers the
|
||||||
|
* middleware returns — see the windowed test), so v1 tells the windowed
|
||||||
|
* stage apart from the quota stage by the MESSAGE alone.
|
||||||
|
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
|
||||||
|
* quota means unlimited and a `0` quota is a hard block.
|
||||||
|
*
|
||||||
|
* --- WHAT THIS V1 SUITE COVERS (and what it intentionally does NOT) ---
|
||||||
|
* api -> every authenticated v1 request (get-api-token-by-token). Ported
|
||||||
|
* 1:1 from the v2 suite against `GET /api/v1/documents`.
|
||||||
|
* email -> resend (`POST /api/v1/documents/:id/resend`) consumes
|
||||||
|
* `recipientsToRemind.length` SYNCHRONOUSLY (resend-document), so we
|
||||||
|
* can assert on the HTTP response rather than racing async jobs.
|
||||||
|
* IMPORTANT V1 DIVERGENCE: the v1 `resendDocument` handler catches
|
||||||
|
* EVERY error and returns a generic HTTP 500
|
||||||
|
* (`{ message: 'An error has occured while resending the document' }`)
|
||||||
|
* — it does NOT surface the org limiter's 429 / `X-RateLimit-*`
|
||||||
|
* headers like the v2 `redistribute` endpoint does. These tests
|
||||||
|
* therefore assert the v1 reality: a blocked resend returns 500 and
|
||||||
|
* the monthly counter advances exactly as documented.
|
||||||
|
* document -> INTENTIONALLY OMITTED. v1's `POST /api/v1/documents` create path
|
||||||
|
* requires S3 upload transport (createEnvelope), which the local E2E
|
||||||
|
* environment generally does not provide, so it cannot be exercised
|
||||||
|
* deterministically here. Document-counter enforcement is covered by
|
||||||
|
* the v2 suite (envelope/create).
|
||||||
|
*
|
||||||
|
* --- WHY THIS TEST IS SKIPPED IN CI ---
|
||||||
|
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
|
||||||
|
* the per-org assertion and the global IP limiter, making every assertion here
|
||||||
|
* meaningless. The test therefore skips itself in that mode and is intended to be
|
||||||
|
* run deliberately and locally with the bypass OFF.
|
||||||
|
*
|
||||||
|
* --- GLOBAL LIMIT AWARENESS ---
|
||||||
|
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||||
|
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||||
|
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||||
|
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||||
|
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||||
|
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
|
||||||
|
* that we hit the org limiter rather than the global one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||||
|
|
||||||
|
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||||
|
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||||
|
// bypass flag, so skip there; run it locally with the bypass turned off.
|
||||||
|
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
|
||||||
|
|
||||||
|
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
|
||||||
|
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
|
||||||
|
|
||||||
|
type ClaimLimits = {
|
||||||
|
apiRateLimits?: RateLimitEntry[];
|
||||||
|
apiQuota?: number | null;
|
||||||
|
documentRateLimits?: RateLimitEntry[];
|
||||||
|
documentQuota?: number | null;
|
||||||
|
emailRateLimits?: RateLimitEntry[];
|
||||||
|
emailQuota?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentMonthlyPeriod = (): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${now.getUTCFullYear()}-${month}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrganisationClaim = async (team: Team) =>
|
||||||
|
prisma.organisationClaim.findFirstOrThrow({
|
||||||
|
where: { organisation: { id: team.organisationId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a clean set of limits to the org's claim. Any counter not provided is
|
||||||
|
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
|
||||||
|
* each other.
|
||||||
|
*/
|
||||||
|
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||||
|
const claim = await getOrganisationClaim(team);
|
||||||
|
|
||||||
|
await prisma.organisationClaim.update({
|
||||||
|
where: { id: claim.id },
|
||||||
|
data: {
|
||||||
|
apiRateLimits: limits.apiRateLimits ?? [],
|
||||||
|
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
|
||||||
|
documentRateLimits: limits.documentRateLimits ?? [],
|
||||||
|
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
|
||||||
|
emailRateLimits: limits.emailRateLimits ?? [],
|
||||||
|
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
|
||||||
|
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||||
|
*
|
||||||
|
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||||
|
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
||||||
|
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||||
|
* Across the suite (and especially across repeated local runs within the same
|
||||||
|
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||||
|
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
|
||||||
|
* one. Since this suite runs deliberately in isolation (it skips in CI), we
|
||||||
|
* clear that bucket here so the global limiter never masks the org assertion.
|
||||||
|
*/
|
||||||
|
const resetUsage = async (organisation: Organisation) => {
|
||||||
|
const period = currentMonthlyPeriod();
|
||||||
|
|
||||||
|
await prisma.organisationMonthlyStat.updateMany({
|
||||||
|
where: { organisationId: organisation.id, period },
|
||||||
|
data: {
|
||||||
|
documentCount: 0,
|
||||||
|
emailCount: 0,
|
||||||
|
apiCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.rateLimit.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v1' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
|
||||||
|
|
||||||
|
const getMonthlyStat = async (organisation: Organisation) =>
|
||||||
|
prisma.organisationMonthlyStat.findUnique({
|
||||||
|
where: {
|
||||||
|
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
|
||||||
|
},
|
||||||
|
select: { documentCount: true, emailCount: true, apiCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert the live OrganisationMonthlyStat counter equals `expected`.
|
||||||
|
*
|
||||||
|
* The DB counter is the source of truth for quota enforcement, so checking its
|
||||||
|
* exact value (not just the HTTP response) proves the documented increment
|
||||||
|
* semantics in check-monthly-quota.ts:
|
||||||
|
* - quota === null -> never incremented (stays 0)
|
||||||
|
* - quota === 0 -> throws BEFORE increment (stays 0)
|
||||||
|
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
|
||||||
|
* even the request that gets rejected still advances it
|
||||||
|
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
|
||||||
|
*/
|
||||||
|
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
|
||||||
|
const stat = await getMonthlyStat(organisation);
|
||||||
|
|
||||||
|
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep until just after the next windowed-limit bucket boundary.
|
||||||
|
*
|
||||||
|
* The limiter (createRateLimit -> getBucket) buckets time as
|
||||||
|
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
|
||||||
|
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
|
||||||
|
* boundary the counter resets mid-test and the expected 429 never happens. We
|
||||||
|
* share the server's clock (same host), so aligning to a fresh bucket here makes
|
||||||
|
* the exhaustion deterministic.
|
||||||
|
*/
|
||||||
|
const alignToFreshWindowBucket = async (windowSeconds: number) => {
|
||||||
|
const windowMs = windowSeconds * 1000;
|
||||||
|
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
|
||||||
|
* of MAX+1 requests completes inside ONE window. Without this, a burst that
|
||||||
|
* happens to cross a bucket boundary would have its count reset mid-test and the
|
||||||
|
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
|
||||||
|
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
|
||||||
|
* is almost always a no-op.
|
||||||
|
*/
|
||||||
|
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
|
||||||
|
const windowMs = windowSeconds * 1000;
|
||||||
|
const msLeftInBucket = windowMs - (Date.now() % windowMs);
|
||||||
|
|
||||||
|
if (msLeftInBucket < requiredHeadroomMs) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ApiErrorBody = { message?: string; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-throwing predicate: true when the response is an ORG-level 429
|
||||||
|
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
|
||||||
|
* which needs a boolean to decide whether to skip rather than fail.
|
||||||
|
*/
|
||||||
|
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
|
||||||
|
if (res.status() !== 429) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
|
||||||
|
|
||||||
|
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
|
||||||
|
return body.message !== undefined && body.error === undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert the response is an ORG-level 429 and return its parsed body.
|
||||||
|
*
|
||||||
|
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
|
||||||
|
* "Expected 429, got <status>: <body>" message instead of an opaque
|
||||||
|
* `expected true, received false`. Also asserts the body is the org limiter's
|
||||||
|
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
|
||||||
|
* global-IP 429 can never be mistaken for the org limit under test.
|
||||||
|
*/
|
||||||
|
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
|
||||||
|
const bodyText = await res.text();
|
||||||
|
|
||||||
|
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
|
||||||
|
|
||||||
|
let body: ApiErrorBody = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = JSON.parse(bodyText) as ApiErrorBody;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
body.message !== undefined && body.error === undefined,
|
||||||
|
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
return body;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert NO org rate-limit header was surfaced — the GLOBAL /api/v1 middleware
|
||||||
|
* still stamps a single `X-RateLimit-Limit: 100`, so "no org header" means the
|
||||||
|
* value is either absent or exactly the lone global `100` (i.e. it does not
|
||||||
|
* contain a second, org-specific entry).
|
||||||
|
*
|
||||||
|
* In v1 this holds for BOTH stages: quota rejections intentionally omit
|
||||||
|
* rate-limit headers, AND windowed rejections lose theirs because the ts-rest
|
||||||
|
* handler ignores the `headers` the middleware returns (see the windowed test).
|
||||||
|
*/
|
||||||
|
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
|
||||||
|
const header = res.headers()['x-ratelimit-limit'];
|
||||||
|
|
||||||
|
if (header === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = header.split(',').map((v) => v.trim());
|
||||||
|
|
||||||
|
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Guard against the global limiter silently masking an org assertion. */
|
||||||
|
const expectNotGlobalLimited = async (res: APIResponse) => {
|
||||||
|
if (res.status() === 429) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
'error' in body && !('message' in body),
|
||||||
|
'Hit the GLOBAL /api/v1 IP limiter, not the org limiter. Re-run this suite in isolation.',
|
||||||
|
).toBeFalsy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
|
||||||
|
const findDocuments = (request: APIRequestContext, token: string): Promise<APIResponse> =>
|
||||||
|
request.get(`${baseUrl}/documents?page=1&perPage=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend (remind) the given recipients. This runs the SYNCHRONOUS email assertion
|
||||||
|
* in resend-document with `count = recipients.length`.
|
||||||
|
*
|
||||||
|
* NOTE: unlike the v2 `redistribute` endpoint, the v1 `resendDocument` handler
|
||||||
|
* wraps everything in a try/catch and returns a generic HTTP 500 on ANY error
|
||||||
|
* (including the org limiter's TOO_MANY_REQUESTS AppError). So when the email
|
||||||
|
* limit/quota is exceeded this resolves to a 500, NOT a 429.
|
||||||
|
*/
|
||||||
|
const resendDocument = (
|
||||||
|
request: APIRequestContext,
|
||||||
|
token: string,
|
||||||
|
documentId: number,
|
||||||
|
recipientIds: number[],
|
||||||
|
): Promise<APIResponse> =>
|
||||||
|
request.post(`${baseUrl}/documents/${documentId}/resend`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { recipients: recipientIds },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert a resend was blocked by the org email limiter.
|
||||||
|
*
|
||||||
|
* v1's handler masks the limiter's 429 as a generic HTTP 500 (see `resendDocument`
|
||||||
|
* above), so the only signal available on the HTTP layer is the 500 status. The
|
||||||
|
* accompanying `expectMonthlyCounter` assertions in each test prove WHICH stage
|
||||||
|
* blocked it (windowed leaves the counter untouched; quota advances it).
|
||||||
|
*/
|
||||||
|
const expectResendBlocked = async (res: APIResponse) => {
|
||||||
|
const bodyText = await res.text();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
res.status(),
|
||||||
|
`Expected the v1 resend to be blocked (masked as HTTP 500) but got ${res.status()} with body: ${bodyText}`,
|
||||||
|
).toBe(500);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a PENDING document with `recipientCount` NOT_SIGNED signer recipients (each
|
||||||
|
* carrying a signature field) created directly via Prisma — so no async signing
|
||||||
|
* emails are fanned out and the monthly email counter starts clean. Returns the
|
||||||
|
* legacy document id (for the resend endpoint) and the recipient ids to remind.
|
||||||
|
*/
|
||||||
|
const seedRemindableDocument = async ({
|
||||||
|
owner,
|
||||||
|
team,
|
||||||
|
recipientCount,
|
||||||
|
}: {
|
||||||
|
owner: User;
|
||||||
|
team: Team;
|
||||||
|
recipientCount: number;
|
||||||
|
}): Promise<{ documentId: number; recipientIds: number[] }> => {
|
||||||
|
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: Array.from(
|
||||||
|
{ length: recipientCount },
|
||||||
|
(_, i) => `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||||
|
),
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||||
|
recipientIds: recipients.map((recipient) => recipient.id),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test.describe('Organisation dynamic rate limits & quotas (API v1)', () => {
|
||||||
|
let user: User;
|
||||||
|
let team: Team;
|
||||||
|
let organisation: Organisation;
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
const seeded = await seedUser();
|
||||||
|
user = seeded.user;
|
||||||
|
team = seeded.team;
|
||||||
|
organisation = seeded.organisation;
|
||||||
|
|
||||||
|
({ token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test-org-rate-limits',
|
||||||
|
expiresIn: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
|
||||||
|
// value that actually matters is the env the SERVER was started with — if the
|
||||||
|
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
|
||||||
|
// fail confusingly instead of skipping. Prove enforcement is live by setting a
|
||||||
|
// quota of 0 (instant hard block) and confirming the server rejects. If it
|
||||||
|
// doesn't, the server is bypassing limits, so skip with a clear message.
|
||||||
|
await setClaimLimits(team, { apiQuota: 0 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const preflight = await findDocuments(request, token);
|
||||||
|
const enforced = await isOrgLimited(preflight);
|
||||||
|
|
||||||
|
// Reset back to a clean slate before the real scenario runs.
|
||||||
|
await setClaimLimits(team, {});
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
!enforced,
|
||||||
|
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// API counter — windowed rate limit
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('api rate limit (windowed)', () => {
|
||||||
|
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
|
||||||
|
const MAX = 4;
|
||||||
|
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
|
||||||
|
await ensureWindowHeadroom(60, 10_000);
|
||||||
|
|
||||||
|
// Each request (including these GETs) consumes one api counter.
|
||||||
|
for (let i = 0; i < MAX; i += 1) {
|
||||||
|
const res = await findDocuments(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request is over the windowed limit.
|
||||||
|
const limitedRes = await findDocuments(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
// The windowed limit uses a message distinct from the global limiter — and
|
||||||
|
// in v1 the MESSAGE is the only signal we get (see note below), so it is how
|
||||||
|
// we tell a windowed rejection apart from a quota one.
|
||||||
|
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||||
|
|
||||||
|
// V1 DIVERGENCE: unlike v2, v1's ts-rest handler does not propagate the org
|
||||||
|
// limiter's `X-RateLimit-*` headers. `authenticatedMiddleware` returns them
|
||||||
|
// on the body object (`headers: err.headers`), which `@ts-rest/serverless`
|
||||||
|
// ignores (custom headers must be written to the `responseHeaders` Headers
|
||||||
|
// object). So only the global middleware's lone `X-RateLimit-Limit: 100`
|
||||||
|
// survives — the org `max` and `Retry-After`/`Remaining` never reach the
|
||||||
|
// client. We therefore assert no org-specific header is surfaced.
|
||||||
|
expectNoOrgRateLimitHeader(limitedRes);
|
||||||
|
|
||||||
|
// Windowed limiting uses the RateLimit bucket table, NOT the monthly quota
|
||||||
|
// counter (quota is null here), so apiCount must remain untouched.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
// Make sure both requests land inside a single 1m bucket.
|
||||||
|
await ensureWindowHeadroom(60, 10_000);
|
||||||
|
|
||||||
|
const okRes = await findDocuments(request, token);
|
||||||
|
await expectNotGlobalLimited(okRes);
|
||||||
|
expect(okRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const limitedRes = await findDocuments(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||||
|
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
|
||||||
|
const MAX = 2;
|
||||||
|
const WINDOW_SECONDS = 3;
|
||||||
|
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
|
||||||
|
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
|
||||||
|
await alignToFreshWindowBucket(WINDOW_SECONDS);
|
||||||
|
|
||||||
|
// Exhaust the window.
|
||||||
|
for (let i = 0; i < MAX; i += 1) {
|
||||||
|
const res = await findDocuments(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request is blocked by the window.
|
||||||
|
const limitedRes = await findDocuments(request, token);
|
||||||
|
await expectOrgLimited(limitedRes);
|
||||||
|
|
||||||
|
// Wait out the window using the server-provided Retry-After (plus a small
|
||||||
|
// buffer to be sure we've crossed into the next time bucket). Crucially we
|
||||||
|
// do NOT reset usage here — the limiter must recover on its own as the
|
||||||
|
// bucket rolls over.
|
||||||
|
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
|
||||||
|
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
|
||||||
|
|
||||||
|
// Window has elapsed: the same org can make requests again without any
|
||||||
|
// manual intervention — the bucket rolled over on its own.
|
||||||
|
const afterReset = await findDocuments(request, token);
|
||||||
|
await expectNotGlobalLimited(afterReset);
|
||||||
|
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// API counter — monthly quota
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('api quota (monthly)', () => {
|
||||||
|
test('null quota allows unlimited requests', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { apiQuota: null });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
const res = await findDocuments(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A null quota means "unlimited" and must never increment the counter.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
|
||||||
|
const QUOTA = 3;
|
||||||
|
await setClaimLimits(team, { apiQuota: QUOTA });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
for (let i = 0; i < QUOTA; i += 1) {
|
||||||
|
const res = await findDocuments(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedRes = await findDocuments(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
|
||||||
|
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
|
||||||
|
expectNoOrgRateLimitHeader(limitedRes);
|
||||||
|
|
||||||
|
// The atomic increment runs even on the rejected request: QUOTA allowed
|
||||||
|
// requests + the one rejected request = exactly QUOTA + 1.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { apiQuota: 1 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const okRes = await findDocuments(request, token);
|
||||||
|
await expectNotGlobalLimited(okRes);
|
||||||
|
expect(okRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const limitedRes = await findDocuments(request, token);
|
||||||
|
await expectOrgLimited(limitedRes);
|
||||||
|
|
||||||
|
// One allowed + one rejected, both incremented => exactly 2.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { apiQuota: 0 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const limitedRes = await findDocuments(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
|
||||||
|
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Email counter — windowed rate limit (via synchronous resend)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('email rate limit (windowed)', () => {
|
||||||
|
test('resend is allowed when recipient count is within the email window', async ({ request }) => {
|
||||||
|
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
|
||||||
|
|
||||||
|
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
|
||||||
|
// seeding above doesn't count against this window.
|
||||||
|
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
// Windowed pass with a null quota must NOT touch the monthly counter.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resend is blocked when recipient count exceeds the email window', async ({ request }) => {
|
||||||
|
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
|
||||||
|
|
||||||
|
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
|
||||||
|
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||||
|
// v1 masks the org 429 as a generic HTTP 500.
|
||||||
|
await expectResendBlocked(res);
|
||||||
|
|
||||||
|
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Email counter — monthly quota (via synchronous resend)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('email quota (monthly)', () => {
|
||||||
|
test('resend within the remaining email quota succeeds', async ({ request }) => {
|
||||||
|
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
|
||||||
|
|
||||||
|
await setClaimLimits(team, { emailQuota: 10 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resend that would exceed the email quota is blocked', async ({ request }) => {
|
||||||
|
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
|
||||||
|
|
||||||
|
// Quota of 2 but reminding 3 recipients in one synchronous call.
|
||||||
|
await setClaimLimits(team, { emailQuota: 2 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||||
|
// v1 masks the org 429 as a generic HTTP 500.
|
||||||
|
await expectResendBlocked(res);
|
||||||
|
|
||||||
|
// The count (3) is added BEFORE the over-quota check throws, so the counter
|
||||||
|
// advances by the full batch even though the request was rejected.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
|
||||||
|
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 1 });
|
||||||
|
|
||||||
|
await setClaimLimits(team, { emailQuota: 0 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||||
|
// v1 masks the org 429 as a generic HTTP 500.
|
||||||
|
await expectResendBlocked(res);
|
||||||
|
|
||||||
|
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Stage interaction — quota binds before a looser window
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('stage interaction', () => {
|
||||||
|
test('the quota trips before a looser windowed limit', async ({ request }) => {
|
||||||
|
const WINDOW_MAX = 50; // generous window
|
||||||
|
const QUOTA = 2; // strict quota — should bind first
|
||||||
|
await setClaimLimits(team, {
|
||||||
|
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
|
||||||
|
apiQuota: QUOTA,
|
||||||
|
});
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
for (let i = 0; i < QUOTA; i += 1) {
|
||||||
|
const res = await findDocuments(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedRes = await findDocuments(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
|
||||||
|
// It must be the QUOTA that bound, not the window: the message is the quota
|
||||||
|
// one (not the windowed-limit message) and there are no rate-limit headers.
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||||
|
expectNoOrgRateLimitHeader(limitedRes);
|
||||||
|
|
||||||
|
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,909 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type {
|
||||||
|
TCreateEnvelopePayload,
|
||||||
|
TCreateEnvelopeResponse,
|
||||||
|
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
|
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||||
|
import type { Organisation, Team, User } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic organisation rate-limit & quota tests.
|
||||||
|
*
|
||||||
|
* Covers the feature added in `feat: add dynamic rate limits`:
|
||||||
|
* - Three counters: `api`, `document`, `email`.
|
||||||
|
* - Two enforcement stages per counter:
|
||||||
|
* 1. Windowed rate limits (`*RateLimits`) — 429 WITH `X-RateLimit-*` headers.
|
||||||
|
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
|
||||||
|
* quota means unlimited and a `0` quota is a hard block.
|
||||||
|
*
|
||||||
|
* Where each counter is consumed:
|
||||||
|
* api -> every authenticated v2 request (get-api-token-by-token).
|
||||||
|
* document -> envelope create where type === DOCUMENT (count 1).
|
||||||
|
* email -> redistribute/remind consumes `recipientsToRemind.length`
|
||||||
|
* SYNCHRONOUSLY (resend-document), so we can assert on the HTTP
|
||||||
|
* response rather than racing async signing-email jobs.
|
||||||
|
*
|
||||||
|
* --- WHY THIS TEST IS SKIPPED IN CI ---
|
||||||
|
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
|
||||||
|
* the per-org assertion and the global IP limiter, making every assertion here
|
||||||
|
* meaningless. The test therefore skips itself in that mode and is intended to be
|
||||||
|
* run deliberately and locally with the bypass OFF.
|
||||||
|
*
|
||||||
|
* --- GLOBAL LIMIT AWARENESS ---
|
||||||
|
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||||
|
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
||||||
|
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||||
|
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||||
|
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||||
|
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
|
||||||
|
* that we hit the org limiter rather than the global one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
|
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||||
|
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||||
|
// bypass flag, so skip there; run it locally with the bypass turned off.
|
||||||
|
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
|
||||||
|
|
||||||
|
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||||
|
|
||||||
|
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
|
||||||
|
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
|
||||||
|
|
||||||
|
type ClaimLimits = {
|
||||||
|
apiRateLimits?: RateLimitEntry[];
|
||||||
|
apiQuota?: number | null;
|
||||||
|
documentRateLimits?: RateLimitEntry[];
|
||||||
|
documentQuota?: number | null;
|
||||||
|
emailRateLimits?: RateLimitEntry[];
|
||||||
|
emailQuota?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentMonthlyPeriod = (): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${now.getUTCFullYear()}-${month}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrganisationClaim = async (team: Team) =>
|
||||||
|
prisma.organisationClaim.findFirstOrThrow({
|
||||||
|
where: { organisation: { id: team.organisationId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a clean set of limits to the org's claim. Any counter not provided is
|
||||||
|
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
|
||||||
|
* each other.
|
||||||
|
*/
|
||||||
|
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||||
|
const claim = await getOrganisationClaim(team);
|
||||||
|
|
||||||
|
await prisma.organisationClaim.update({
|
||||||
|
where: { id: claim.id },
|
||||||
|
data: {
|
||||||
|
apiRateLimits: limits.apiRateLimits ?? [],
|
||||||
|
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
|
||||||
|
documentRateLimits: limits.documentRateLimits ?? [],
|
||||||
|
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
|
||||||
|
emailRateLimits: limits.emailRateLimits ?? [],
|
||||||
|
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
|
||||||
|
* GLOBAL /api/v2 IP bucket so a fresh scenario starts from zero.
|
||||||
|
*
|
||||||
|
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||||
|
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV2RateLimit, 100/min
|
||||||
|
* per IP, action `api.v2`) is shared by EVERY v2 request from this test client.
|
||||||
|
* Across the suite (and especially across repeated local runs within the same
|
||||||
|
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||||
|
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
|
||||||
|
* one. Since this suite runs deliberately in isolation (it skips in CI), we
|
||||||
|
* clear that bucket here so the global limiter never masks the org assertion.
|
||||||
|
*/
|
||||||
|
const resetUsage = async (organisation: Organisation) => {
|
||||||
|
const period = currentMonthlyPeriod();
|
||||||
|
|
||||||
|
await prisma.organisationMonthlyStat.updateMany({
|
||||||
|
where: { organisationId: organisation.id, period },
|
||||||
|
data: {
|
||||||
|
documentCount: 0,
|
||||||
|
emailCount: 0,
|
||||||
|
apiCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.rateLimit.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v2' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
|
||||||
|
|
||||||
|
const getMonthlyStat = async (organisation: Organisation) =>
|
||||||
|
prisma.organisationMonthlyStat.findUnique({
|
||||||
|
where: {
|
||||||
|
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
|
||||||
|
},
|
||||||
|
select: { documentCount: true, emailCount: true, apiCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert the live OrganisationMonthlyStat counter equals `expected`.
|
||||||
|
*
|
||||||
|
* The DB counter is the source of truth for quota enforcement, so checking its
|
||||||
|
* exact value (not just the HTTP response) proves the documented increment
|
||||||
|
* semantics in check-monthly-quota.ts:
|
||||||
|
* - quota === null -> never incremented (stays 0)
|
||||||
|
* - quota === 0 -> throws BEFORE increment (stays 0)
|
||||||
|
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
|
||||||
|
* even the request that gets rejected still advances it
|
||||||
|
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
|
||||||
|
*/
|
||||||
|
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
|
||||||
|
const stat = await getMonthlyStat(organisation);
|
||||||
|
|
||||||
|
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until a monthly counter reaches `atLeast` and then stops changing.
|
||||||
|
*
|
||||||
|
* `distribute` fans out one async signing-request email job per recipient (the
|
||||||
|
* local job runner fires them via fire-and-forget HTTP, so they complete after
|
||||||
|
* the call returns). Each job increments emailCount. We poll until the counter
|
||||||
|
* has reached the expected floor AND is stable across consecutive reads, which
|
||||||
|
* guarantees no late job will increment the counter after the caller resets
|
||||||
|
* usage — making the subsequent (synchronous) redistribute assertions exact.
|
||||||
|
*/
|
||||||
|
const waitForCounterToSettle = async (
|
||||||
|
organisation: Organisation,
|
||||||
|
counter: MonthlyCounter,
|
||||||
|
atLeast: number,
|
||||||
|
timeoutMs = 20_000,
|
||||||
|
): Promise<number> => {
|
||||||
|
const start = Date.now();
|
||||||
|
let previous = -1;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const stat = await getMonthlyStat(organisation);
|
||||||
|
const current = stat?.[counter] ?? 0;
|
||||||
|
|
||||||
|
if (current >= atLeast && current === previous) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
previous = current;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for ${counter} to settle at >= ${atLeast}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep until just after the next windowed-limit bucket boundary.
|
||||||
|
*
|
||||||
|
* The limiter (createRateLimit -> getBucket) buckets time as
|
||||||
|
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
|
||||||
|
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
|
||||||
|
* boundary the counter resets mid-test and the expected 429 never happens. We
|
||||||
|
* share the server's clock (same host), so aligning to a fresh bucket here makes
|
||||||
|
* the exhaustion deterministic.
|
||||||
|
*/
|
||||||
|
const alignToFreshWindowBucket = async (windowSeconds: number) => {
|
||||||
|
const windowMs = windowSeconds * 1000;
|
||||||
|
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
|
||||||
|
* of MAX+1 requests completes inside ONE window. Without this, a burst that
|
||||||
|
* happens to cross a bucket boundary would have its count reset mid-test and the
|
||||||
|
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
|
||||||
|
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
|
||||||
|
* is almost always a no-op.
|
||||||
|
*/
|
||||||
|
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
|
||||||
|
const windowMs = windowSeconds * 1000;
|
||||||
|
const msLeftInBucket = windowMs - (Date.now() % windowMs);
|
||||||
|
|
||||||
|
if (msLeftInBucket < requiredHeadroomMs) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ApiErrorBody = { message?: string; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-throwing predicate: true when the response is an ORG-level 429
|
||||||
|
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
|
||||||
|
* which needs a boolean to decide whether to skip rather than fail.
|
||||||
|
*/
|
||||||
|
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
|
||||||
|
if (res.status() !== 429) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
|
||||||
|
|
||||||
|
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
|
||||||
|
return body.message !== undefined && body.error === undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert the response is an ORG-level 429 and return its parsed body.
|
||||||
|
*
|
||||||
|
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
|
||||||
|
* "Expected 429, got <status>: <body>" message instead of an opaque
|
||||||
|
* `expected true, received false`. Also asserts the body is the org limiter's
|
||||||
|
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
|
||||||
|
* global-IP 429 can never be mistaken for the org limit under test.
|
||||||
|
*/
|
||||||
|
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
|
||||||
|
const bodyText = await res.text();
|
||||||
|
|
||||||
|
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
|
||||||
|
|
||||||
|
let body: ApiErrorBody = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = JSON.parse(bodyText) as ApiErrorBody;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
body.message !== undefined && body.error === undefined,
|
||||||
|
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
return body;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert the org windowed-limit value is present in `X-RateLimit-Limit`.
|
||||||
|
*
|
||||||
|
* Two limiters set this header: the GLOBAL /api/v2 middleware (max 100) sets it
|
||||||
|
* first, then the org limiter's AppError sets it to the org `max`. Playwright
|
||||||
|
* surfaces duplicate headers joined by ", " (e.g. "100, 4"), so we assert the
|
||||||
|
* org value is one of the comma-separated entries rather than an exact match.
|
||||||
|
*/
|
||||||
|
const expectRateLimitHeaderToInclude = (res: APIResponse, expectedMax: number) => {
|
||||||
|
const header = res.headers()['x-ratelimit-limit'] ?? '';
|
||||||
|
const values = header.split(',').map((v) => v.trim());
|
||||||
|
|
||||||
|
expect(values, `X-RateLimit-Limit "${header}" should include the org max ${expectedMax}`).toContain(
|
||||||
|
String(expectedMax),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert NO org rate-limit header was added — used for quota rejections, which
|
||||||
|
* intentionally omit rate-limit headers (a quota isn't a window). The GLOBAL
|
||||||
|
* middleware still stamps a single `X-RateLimit-Limit: 100`, so "no org header"
|
||||||
|
* means the value is either absent or exactly the lone global `100` (i.e. it does
|
||||||
|
* not contain a second, org-specific entry).
|
||||||
|
*/
|
||||||
|
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
|
||||||
|
const header = res.headers()['x-ratelimit-limit'];
|
||||||
|
|
||||||
|
if (header === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = header.split(',').map((v) => v.trim());
|
||||||
|
|
||||||
|
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Guard against the global limiter silently masking an org assertion. */
|
||||||
|
const expectNotGlobalLimited = async (res: APIResponse) => {
|
||||||
|
if (res.status() === 429) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
'error' in body && !('message' in body),
|
||||||
|
'Hit the GLOBAL /api/v2 IP limiter, not the org limiter. Re-run this suite in isolation.',
|
||||||
|
).toBeFalsy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
|
||||||
|
const findEnvelopes = (request: APIRequestContext, token: string): Promise<APIResponse> =>
|
||||||
|
request.get(`${baseUrl}/envelope?perPage=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a DOCUMENT envelope. Consumes one `api` counter and, when
|
||||||
|
* `type === DOCUMENT`, one `document` counter. Optionally seeds SIGNER recipients
|
||||||
|
* (each with a signature field) so the envelope can later be distributed.
|
||||||
|
*/
|
||||||
|
const createEnvelope = async (
|
||||||
|
request: APIRequestContext,
|
||||||
|
token: string,
|
||||||
|
options: { recipientCount?: number } = {},
|
||||||
|
): Promise<APIResponse> => {
|
||||||
|
const { recipientCount = 0 } = options;
|
||||||
|
|
||||||
|
const payload: TCreateEnvelopePayload = {
|
||||||
|
title: `Rate limit test ${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
recipients:
|
||||||
|
recipientCount > 0
|
||||||
|
? Array.from({ length: recipientCount }, (_, i) => ({
|
||||||
|
email: `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||||
|
name: `Recipient ${i}`,
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: i + 1,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'SIGNATURE',
|
||||||
|
fieldMeta: { type: 'signature', overflow: 'crop' },
|
||||||
|
identifier: 0,
|
||||||
|
page: 1,
|
||||||
|
positionX: 10,
|
||||||
|
positionY: 80,
|
||||||
|
width: 20,
|
||||||
|
height: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
meta: {
|
||||||
|
subject: 'Rate limit test',
|
||||||
|
message: 'Automated rate-limit test. Ignore.',
|
||||||
|
distributionMethod: 'EMAIL',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||||
|
|
||||||
|
return request.post(`${baseUrl}/envelope/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
multipart: formData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Distribute an envelope to all of its recipients via EMAIL. */
|
||||||
|
const distributeEnvelope = (request: APIRequestContext, token: string, envelopeId: string): Promise<APIResponse> =>
|
||||||
|
request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
data: {
|
||||||
|
envelopeId,
|
||||||
|
meta: { distributionMethod: 'EMAIL', subject: 'Rate limit test', message: 'Rate limit test' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redistribute (remind) the given recipients. This runs the SYNCHRONOUS email
|
||||||
|
* assertion in resend-document with `count = recipients.length`, returning a 429
|
||||||
|
* directly when the email limit/quota is exceeded.
|
||||||
|
*/
|
||||||
|
const redistributeEnvelope = (
|
||||||
|
request: APIRequestContext,
|
||||||
|
token: string,
|
||||||
|
envelopeId: string,
|
||||||
|
recipientIds: number[],
|
||||||
|
): Promise<APIResponse> =>
|
||||||
|
request.post(`${baseUrl}/envelope/redistribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { envelopeId, recipients: recipientIds },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fully-distributed envelope and return its NOT_SIGNED recipient IDs so a
|
||||||
|
* subsequent redistribute can exercise the synchronous email assertion.
|
||||||
|
*
|
||||||
|
* Setup uses a GENEROUS email quota so the async signing-request emails fanned out
|
||||||
|
* by `distribute` are counted, then waits for that counter to settle. This drains
|
||||||
|
* the background jobs BEFORE the caller resets usage, so they can't pollute
|
||||||
|
* emailCount mid-test. The caller then configures the email limit/quota under test
|
||||||
|
* and resets usage, so only the (synchronous, deterministic) redistribute counts.
|
||||||
|
*/
|
||||||
|
const seedDistributedEnvelope = async ({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
team,
|
||||||
|
organisation,
|
||||||
|
recipientCount,
|
||||||
|
}: {
|
||||||
|
request: APIRequestContext;
|
||||||
|
token: string;
|
||||||
|
team: Team;
|
||||||
|
organisation: Organisation;
|
||||||
|
recipientCount: number;
|
||||||
|
}): Promise<{ envelopeId: string; recipientIds: number[] }> => {
|
||||||
|
await setClaimLimits(team, { emailQuota: 1000 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const createRes = await createEnvelope(request, token, { recipientCount });
|
||||||
|
expect(createRes.ok(), `create failed: ${await createRes.text()}`).toBeTruthy();
|
||||||
|
const { id: envelopeId } = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||||
|
|
||||||
|
const distributeRes = await distributeEnvelope(request, token, envelopeId);
|
||||||
|
expect(distributeRes.ok(), `distribute failed: ${await distributeRes.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: { envelopeId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drain the async signing-request email jobs (one per recipient) so a late job
|
||||||
|
// cannot increment emailCount after the caller's resetUsage.
|
||||||
|
await waitForCounterToSettle(organisation, 'emailCount', recipientCount);
|
||||||
|
|
||||||
|
return { envelopeId, recipientIds: recipients.map((r) => r.id) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test.describe('Organisation dynamic rate limits & quotas', () => {
|
||||||
|
let user: User;
|
||||||
|
let team: Team;
|
||||||
|
let organisation: Organisation;
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
const seeded = await seedUser();
|
||||||
|
user = seeded.user;
|
||||||
|
team = seeded.team;
|
||||||
|
organisation = seeded.organisation;
|
||||||
|
|
||||||
|
({ token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test-org-rate-limits',
|
||||||
|
expiresIn: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
|
||||||
|
// value that actually matters is the env the SERVER was started with — if the
|
||||||
|
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
|
||||||
|
// fail confusingly instead of skipping. Prove enforcement is live by setting a
|
||||||
|
// quota of 0 (instant hard block) and confirming the server rejects. If it
|
||||||
|
// doesn't, the server is bypassing limits, so skip with a clear message.
|
||||||
|
await setClaimLimits(team, { apiQuota: 0 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const preflight = await findEnvelopes(request, token);
|
||||||
|
const enforced = await isOrgLimited(preflight);
|
||||||
|
|
||||||
|
// Reset back to a clean slate before the real scenario runs.
|
||||||
|
await setClaimLimits(team, {});
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
!enforced,
|
||||||
|
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// API counter — windowed rate limit
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('api rate limit (windowed)', () => {
|
||||||
|
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
|
||||||
|
const MAX = 4;
|
||||||
|
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
|
||||||
|
await ensureWindowHeadroom(60, 10_000);
|
||||||
|
|
||||||
|
// Each request (including these GETs) consumes one api counter.
|
||||||
|
for (let i = 0; i < MAX; i += 1) {
|
||||||
|
const res = await findEnvelopes(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request is over the windowed limit.
|
||||||
|
const limitedRes = await findEnvelopes(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
// The windowed limit uses a message distinct from the global limiter.
|
||||||
|
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||||
|
expectRateLimitHeaderToInclude(limitedRes, MAX);
|
||||||
|
expect(limitedRes.headers()['x-ratelimit-remaining']).toContain('0');
|
||||||
|
expect(limitedRes.headers()['retry-after']).toBeTruthy();
|
||||||
|
|
||||||
|
// Windowed limiting uses the RateLimit bucket table, NOT the monthly quota
|
||||||
|
// counter (quota is null here), so apiCount must remain untouched.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
// Make sure both requests land inside a single 1m bucket.
|
||||||
|
await ensureWindowHeadroom(60, 10_000);
|
||||||
|
|
||||||
|
const okRes = await findEnvelopes(request, token);
|
||||||
|
await expectNotGlobalLimited(okRes);
|
||||||
|
expect(okRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const limitedRes = await findEnvelopes(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||||
|
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
|
||||||
|
const MAX = 2;
|
||||||
|
const WINDOW_SECONDS = 3;
|
||||||
|
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
|
||||||
|
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
|
||||||
|
await alignToFreshWindowBucket(WINDOW_SECONDS);
|
||||||
|
|
||||||
|
// Exhaust the window.
|
||||||
|
for (let i = 0; i < MAX; i += 1) {
|
||||||
|
const res = await findEnvelopes(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request is blocked by the window.
|
||||||
|
const limitedRes = await findEnvelopes(request, token);
|
||||||
|
await expectOrgLimited(limitedRes);
|
||||||
|
|
||||||
|
// Wait out the window using the server-provided Retry-After (plus a small
|
||||||
|
// buffer to be sure we've crossed into the next time bucket). Crucially we
|
||||||
|
// do NOT reset usage here — the limiter must recover on its own as the
|
||||||
|
// bucket rolls over.
|
||||||
|
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
|
||||||
|
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
|
||||||
|
|
||||||
|
// Window has elapsed: the same org can make requests again without any
|
||||||
|
// manual intervention — the bucket rolled over on its own.
|
||||||
|
const afterReset = await findEnvelopes(request, token);
|
||||||
|
await expectNotGlobalLimited(afterReset);
|
||||||
|
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// API counter — monthly quota
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('api quota (monthly)', () => {
|
||||||
|
test('null quota allows unlimited requests', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { apiQuota: null });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
const res = await findEnvelopes(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A null quota means "unlimited" and must never increment the counter.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
|
||||||
|
const QUOTA = 3;
|
||||||
|
await setClaimLimits(team, { apiQuota: QUOTA });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
for (let i = 0; i < QUOTA; i += 1) {
|
||||||
|
const res = await findEnvelopes(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedRes = await findEnvelopes(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
|
||||||
|
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
|
||||||
|
expectNoOrgRateLimitHeader(limitedRes);
|
||||||
|
|
||||||
|
// The atomic increment runs even on the rejected request: QUOTA allowed
|
||||||
|
// requests + the one rejected request = exactly QUOTA + 1.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { apiQuota: 1 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const okRes = await findEnvelopes(request, token);
|
||||||
|
await expectNotGlobalLimited(okRes);
|
||||||
|
expect(okRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const limitedRes = await findEnvelopes(request, token);
|
||||||
|
await expectOrgLimited(limitedRes);
|
||||||
|
|
||||||
|
// One allowed + one rejected, both incremented => exactly 2.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { apiQuota: 0 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const limitedRes = await findEnvelopes(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
|
||||||
|
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Document counter — windowed rate limit
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('document rate limit (windowed)', () => {
|
||||||
|
test('allows creates up to the limit then 429s with headers', async ({ request }) => {
|
||||||
|
const MAX = 3;
|
||||||
|
// Keep api unlimited so only the document stage can trip.
|
||||||
|
await setClaimLimits(team, { documentRateLimits: [{ window: '1m', max: MAX }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
// Make sure the MAX+1 create burst lands inside a single 1m bucket.
|
||||||
|
await ensureWindowHeadroom(60, 10_000);
|
||||||
|
|
||||||
|
for (let i = 0; i < MAX; i += 1) {
|
||||||
|
const res = await createEnvelope(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.ok(), `create #${i + 1} should succeed`).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedRes = await createEnvelope(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||||
|
expectRateLimitHeaderToInclude(limitedRes, MAX);
|
||||||
|
expect(limitedRes.headers()['retry-after']).toBeTruthy();
|
||||||
|
|
||||||
|
// Windowed limiting doesn't touch the quota counter (documentQuota is null).
|
||||||
|
await expectMonthlyCounter(organisation, 'documentCount', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Document counter — monthly quota
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('document quota (monthly)', () => {
|
||||||
|
test('exhausting the document quota blocks further creates', async ({ request }) => {
|
||||||
|
const QUOTA = 2;
|
||||||
|
await setClaimLimits(team, { documentQuota: QUOTA });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
for (let i = 0; i < QUOTA; i += 1) {
|
||||||
|
const res = await createEnvelope(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.ok(), `create #${i + 1} should be within quota`).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedRes = await createEnvelope(request, token);
|
||||||
|
await expectOrgLimited(limitedRes);
|
||||||
|
|
||||||
|
// QUOTA successful creates + the rejected one (incremented before throwing).
|
||||||
|
await expectMonthlyCounter(organisation, 'documentCount', QUOTA + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document quota of 0 hard-blocks creation', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { documentQuota: 0 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const limitedRes = await createEnvelope(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
|
||||||
|
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||||
|
await expectMonthlyCounter(organisation, 'documentCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null document quota allows creation', async ({ request }) => {
|
||||||
|
await setClaimLimits(team, { documentQuota: null });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await createEnvelope(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
// A null quota means "unlimited" and must not increment the counter.
|
||||||
|
await expectMonthlyCounter(organisation, 'documentCount', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Email counter — windowed rate limit (via synchronous redistribute)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('email rate limit (windowed)', () => {
|
||||||
|
test('redistribute is allowed when recipient count is within the email window', async ({ request }) => {
|
||||||
|
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
team,
|
||||||
|
organisation,
|
||||||
|
recipientCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
|
||||||
|
// create/distribute consumption above doesn't count against this window.
|
||||||
|
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
// Windowed pass with a null quota must NOT touch the monthly counter.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redistribute is blocked when recipient count exceeds the email window', async ({ request }) => {
|
||||||
|
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
team,
|
||||||
|
organisation,
|
||||||
|
recipientCount: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
|
||||||
|
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||||
|
const body = await expectOrgLimited(res);
|
||||||
|
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||||
|
expectRateLimitHeaderToInclude(res, 2);
|
||||||
|
|
||||||
|
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Email counter — monthly quota (via synchronous redistribute)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('email quota (monthly)', () => {
|
||||||
|
test('redistribute within the remaining email quota succeeds', async ({ request }) => {
|
||||||
|
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
team,
|
||||||
|
organisation,
|
||||||
|
recipientCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setClaimLimits(team, { emailQuota: 10 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redistribute that would exceed the email quota is blocked', async ({ request }) => {
|
||||||
|
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
team,
|
||||||
|
organisation,
|
||||||
|
recipientCount: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quota of 2 but reminding 3 recipients in one synchronous call.
|
||||||
|
await setClaimLimits(team, { emailQuota: 2 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||||
|
const body = await expectOrgLimited(res);
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
|
||||||
|
// Quota rejection carries no rate-limit headers.
|
||||||
|
expectNoOrgRateLimitHeader(res);
|
||||||
|
|
||||||
|
// The count (3) is added BEFORE the over-quota check throws, so the counter
|
||||||
|
// advances by the full batch even though the request was rejected.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
|
||||||
|
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
team,
|
||||||
|
organisation,
|
||||||
|
recipientCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setClaimLimits(team, { emailQuota: 0 });
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||||
|
const body = await expectOrgLimited(res);
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
|
||||||
|
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||||
|
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Stage interaction — quota binds before a looser window
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test.describe('stage interaction', () => {
|
||||||
|
test('the quota trips before a looser windowed limit', async ({ request }) => {
|
||||||
|
const WINDOW_MAX = 50; // generous window
|
||||||
|
const QUOTA = 2; // strict quota — should bind first
|
||||||
|
await setClaimLimits(team, {
|
||||||
|
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
|
||||||
|
apiQuota: QUOTA,
|
||||||
|
});
|
||||||
|
await resetUsage(organisation);
|
||||||
|
|
||||||
|
for (let i = 0; i < QUOTA; i += 1) {
|
||||||
|
const res = await findEnvelopes(request, token);
|
||||||
|
await expectNotGlobalLimited(res);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedRes = await findEnvelopes(request, token);
|
||||||
|
const body = await expectOrgLimited(limitedRes);
|
||||||
|
|
||||||
|
// It must be the QUOTA that bound, not the window: the message is the quota
|
||||||
|
// one (not the windowed-limit message) and there are no rate-limit headers.
|
||||||
|
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||||
|
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||||
|
expectNoOrgRateLimitHeader(limitedRes);
|
||||||
|
|
||||||
|
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
|
||||||
|
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type {
|
||||||
|
TCreateEnvelopePayload,
|
||||||
|
TCreateEnvelopeResponse,
|
||||||
|
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
|
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||||
|
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||||
|
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||||
|
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||||
|
import type { Team, User } from '@prisma/client';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
|
test.describe.configure({
|
||||||
|
mode: 'parallel',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the `recipientCount` limit on the organisation that owns the seeded team.
|
||||||
|
*
|
||||||
|
* A value of `0` means unlimited recipients are allowed.
|
||||||
|
*/
|
||||||
|
const setOrganisationRecipientCount = async (team: Team, recipientCount: number) => {
|
||||||
|
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
organisation: {
|
||||||
|
id: team.organisationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.organisationClaim.update({
|
||||||
|
where: {
|
||||||
|
id: organisationClaim.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
recipientCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
|
||||||
|
const payload: TCreateEnvelopePayload = {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
title: 'Recipient Count Limit Test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||||
|
|
||||||
|
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}` },
|
||||||
|
multipart: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
return (await res.json()) as TCreateEnvelopeResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
|
||||||
|
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
return (await res.json()) as TGetEnvelopeResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an envelope with exactly `recipientCount` SIGNER recipients, each with
|
||||||
|
* their own signature field, then attempt to distribute it.
|
||||||
|
*
|
||||||
|
* Returns the raw distribute response so the caller can assert on the status.
|
||||||
|
*/
|
||||||
|
const buildAndDistributeEnvelopeWithRecipients = async ({
|
||||||
|
request,
|
||||||
|
authToken,
|
||||||
|
recipientCount,
|
||||||
|
}: {
|
||||||
|
request: APIRequestContext;
|
||||||
|
authToken: string;
|
||||||
|
recipientCount: number;
|
||||||
|
}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
|
||||||
|
const envelope = await createEnvelope(request, authToken);
|
||||||
|
|
||||||
|
// Create N SIGNER recipients in a single request.
|
||||||
|
const recipientData = Array.from({ length: recipientCount }).map((_, index) => ({
|
||||||
|
email: `recipient-${index}-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||||
|
name: `Recipient ${index}`,
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||||
|
data: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
data: recipientData,
|
||||||
|
} satisfies TCreateEnvelopeRecipientsRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recipientsRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const recipients = (await recipientsRes.json()).data;
|
||||||
|
|
||||||
|
// Resolve the envelope item ID to place fields on.
|
||||||
|
const envelopeData = await getEnvelope(request, authToken, envelope.id);
|
||||||
|
const envelopeItemId = envelopeData.envelopeItems[0].id;
|
||||||
|
|
||||||
|
// Each SIGNER must have a signature field, otherwise distribution fails for
|
||||||
|
// a reason unrelated to the recipient count.
|
||||||
|
const fieldData = recipients.map((recipient: { id: number }) => ({
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeItemId,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 100,
|
||||||
|
positionY: 100,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||||
|
data: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
data: fieldData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fieldsRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
// Attempt to distribute the envelope.
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||||
|
data: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
} satisfies TDistributeEnvelopeRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { envelopeId: envelope.id, distributeRes };
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
|
||||||
|
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope.status).toBe(status);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Recipient count limit on distribute', () => {
|
||||||
|
let user: User;
|
||||||
|
let team: Team;
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
({ user, team } = await seedUser());
|
||||||
|
({ token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test-recipient-count-limit',
|
||||||
|
expiresIn: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Limit = 3. Edge cases around the boundary: 2 (under), 3 (at), 4 (over).
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('should allow distribution when recipient count is below the limit', async ({ request }) => {
|
||||||
|
await setOrganisationRecipientCount(team, 3);
|
||||||
|
|
||||||
|
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||||
|
request,
|
||||||
|
authToken: token,
|
||||||
|
recipientCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
|
||||||
|
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow distribution when recipient count is exactly at the limit', async ({ request }) => {
|
||||||
|
await setOrganisationRecipientCount(team, 3);
|
||||||
|
|
||||||
|
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||||
|
request,
|
||||||
|
authToken: token,
|
||||||
|
recipientCount: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
|
||||||
|
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should deny distribution when recipient count is one over the limit', async ({ request }) => {
|
||||||
|
await setOrganisationRecipientCount(team, 3);
|
||||||
|
|
||||||
|
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||||
|
request,
|
||||||
|
authToken: token,
|
||||||
|
recipientCount: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeFalsy();
|
||||||
|
expect(distributeRes.status()).toBe(400);
|
||||||
|
|
||||||
|
// The envelope must remain a DRAFT — distribution was rejected.
|
||||||
|
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Limit = 1. The smallest non-unlimited boundary.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('should allow distribution with a single recipient when the limit is 1', async ({ request }) => {
|
||||||
|
await setOrganisationRecipientCount(team, 1);
|
||||||
|
|
||||||
|
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||||
|
request,
|
||||||
|
authToken: token,
|
||||||
|
recipientCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
|
||||||
|
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should deny distribution with two recipients when the limit is 1', async ({ request }) => {
|
||||||
|
await setOrganisationRecipientCount(team, 1);
|
||||||
|
|
||||||
|
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||||
|
request,
|
||||||
|
authToken: token,
|
||||||
|
recipientCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeFalsy();
|
||||||
|
expect(distributeRes.status()).toBe(400);
|
||||||
|
|
||||||
|
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Limit = 0 means unlimited recipients are allowed.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('should allow distribution with many recipients when the limit is 0 (unlimited)', async ({ request }) => {
|
||||||
|
await setOrganisationRecipientCount(team, 0);
|
||||||
|
|
||||||
|
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||||
|
request,
|
||||||
|
authToken: token,
|
||||||
|
recipientCount: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
|
||||||
|
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,3 +28,10 @@ export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
|||||||
* Used as an initial value for the frontend before values are loaded from the server.
|
* Used as an initial value for the frontend before values are loaded from the server.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT = 5;
|
export const DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used as an initial value for the frontend before values are loaded from the server.
|
||||||
|
*
|
||||||
|
* 0 = Unlimited recipients.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_RECIPIENT_COUNT = 20;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
|
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||||
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { SubscriptionStatus } from '@prisma/client';
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
|
||||||
import { extractStripeClaimId } from './on-subscription-updated';
|
import { extractStripeClaimId } from './on-subscription-updated';
|
||||||
|
|
||||||
export type OnSubscriptionDeletedOptions = {
|
export type OnSubscriptionDeletedOptions = {
|
||||||
@@ -34,6 +34,8 @@ export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDele
|
|||||||
// Individuals get their subscription deleted so they can return to the
|
// Individuals get their subscription deleted so they can return to the
|
||||||
// free plan.
|
// free plan.
|
||||||
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||||
|
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.subscription.delete({
|
await tx.subscription.delete({
|
||||||
where: {
|
where: {
|
||||||
@@ -47,7 +49,7 @@ export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDele
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||||
...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]),
|
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||||
|
import { useBranding } from '../providers/branding';
|
||||||
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
import TemplateImage from '../template-components/template-image';
|
||||||
|
|
||||||
|
export type OrganisationLimitExceededEmailProps = {
|
||||||
|
assetBaseUrl: string;
|
||||||
|
organisationName: string;
|
||||||
|
counter: 'document' | 'email' | 'api';
|
||||||
|
kind: 'rateLimit' | 'quota';
|
||||||
|
period: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationLimitExceededEmailTemplate = ({
|
||||||
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
organisationName = 'Organisation Name',
|
||||||
|
counter = 'email',
|
||||||
|
kind = 'quota',
|
||||||
|
period = '2026-05',
|
||||||
|
}: OrganisationLimitExceededEmailProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const branding = useBranding();
|
||||||
|
|
||||||
|
const previewText = msg`Organisation Review Required`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Section className="bg-white text-slate-500">
|
||||||
|
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||||
|
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||||
|
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||||
|
) : (
|
||||||
|
<TemplateImage assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" staticAsset="logo.png" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section className="p-2 text-slate-500">
|
||||||
|
<Text className="text-center font-medium text-black text-lg">
|
||||||
|
<Trans>Organisation Review Required</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{kind === 'quota' ? (
|
||||||
|
<Text className="text-center text-base">
|
||||||
|
{match(counter)
|
||||||
|
.with('document', () => (
|
||||||
|
<Trans>
|
||||||
|
We've noticed document activity on your account that exceeds the fair use limits of your current
|
||||||
|
plan. As a precaution, new document activity has been temporarily paused pending review.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('email', () => (
|
||||||
|
<Trans>
|
||||||
|
We've noticed email sending activity on your account that exceeds the fair use limits of your
|
||||||
|
current plan. As a precaution, new email activity has been temporarily paused pending review.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('api', () => (
|
||||||
|
<Trans>
|
||||||
|
We've noticed API activity on your account that exceeds the fair use limits of your current
|
||||||
|
plan. As a precaution, new API activity has been temporarily paused pending review.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="text-center text-base">
|
||||||
|
{match(counter)
|
||||||
|
.with('document', () => (
|
||||||
|
<Trans>
|
||||||
|
Your organisation is generating documents faster than normal, so some requests are being
|
||||||
|
temporarily throttled.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('email', () => (
|
||||||
|
<Trans>
|
||||||
|
Your organisation is generating emails faster than normal, so some requests are being
|
||||||
|
temporarily throttled.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('api', () => (
|
||||||
|
<Trans>
|
||||||
|
Your organisation is generating API requests faster than normal, so some requests are being
|
||||||
|
temporarily throttled.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text className="text-center text-base">
|
||||||
|
<Trans>Please contact support at {SUPPORT_EMAIL} and we will review your account.</Trans>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter isDocument={false} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrganisationLimitExceededEmailTemplate;
|
||||||
@@ -4,6 +4,7 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
|
|||||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
import { SEND_DOCUMENT_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_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_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
|
||||||
|
import { SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-exceeded-email';
|
||||||
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-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_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_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
|
||||||
@@ -36,6 +37,7 @@ export const jobsClient = new JobClient([
|
|||||||
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
|
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
|
||||||
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
|
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
|
||||||
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
|
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
|
||||||
|
SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION,
|
||||||
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
|
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
|
||||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||||
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
|
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import OrganisationLimitExceededEmailTemplate from '@documenso/email/templates/organisation-limit-exceeded';
|
||||||
|
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 { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
|
||||||
|
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||||
|
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TSendOrganisationLimitExceededEmailJobDefinition } from './send-organisation-limit-exceeded-email';
|
||||||
|
|
||||||
|
export const run = async ({
|
||||||
|
payload,
|
||||||
|
io,
|
||||||
|
}: {
|
||||||
|
payload: TSendOrganisationLimitExceededEmailJobDefinition;
|
||||||
|
io: JobRunIO;
|
||||||
|
}) => {
|
||||||
|
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: payload.organisationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
where: {
|
||||||
|
organisationGroupMembers: {
|
||||||
|
some: {
|
||||||
|
group: {
|
||||||
|
organisationRole: {
|
||||||
|
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||||
|
emailType: 'INTERNAL',
|
||||||
|
source: {
|
||||||
|
type: 'organisation',
|
||||||
|
organisationId: organisation.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const member of organisation.members) {
|
||||||
|
await io.runTask(`send-organisation-limit-exceeded-email-${member.id}`, async () => {
|
||||||
|
const emailContent = createElement(OrganisationLimitExceededEmailTemplate, {
|
||||||
|
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||||
|
organisationName: organisation.name,
|
||||||
|
counter: payload.counter,
|
||||||
|
kind: payload.kind,
|
||||||
|
period: payload.period,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [html, text] = await Promise.all([
|
||||||
|
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding }),
|
||||||
|
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding, plainText: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const i18n = await getI18nInstance(emailLanguage);
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: member.user.email,
|
||||||
|
from: senderEmail,
|
||||||
|
subject: i18n._(msg`Organisation Review Required`),
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limit-exceeded.email';
|
||||||
|
|
||||||
|
const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||||
|
organisationId: z.string(),
|
||||||
|
counter: z.enum(['document', 'email', 'api']),
|
||||||
|
kind: z.enum(['rateLimit', 'quota']),
|
||||||
|
period: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSendOrganisationLimitExceededEmailJobDefinition = z.infer<
|
||||||
|
typeof SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION = {
|
||||||
|
id: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
|
||||||
|
name: 'Send Organisation Limit Exceeded Email',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
|
||||||
|
schema: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./send-organisation-limit-exceeded-email.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
|
||||||
|
TSendOrganisationLimitExceededEmailJobDefinition
|
||||||
|
>;
|
||||||
@@ -16,8 +16,8 @@ import { createElement } from 'react';
|
|||||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||||
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
|
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
|
||||||
import { assertOrgEmailSendAllowed } from '../../../server-only/email/assert-org-email-send-allowed';
|
|
||||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
|
||||||
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
|
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||||
@@ -84,7 +84,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId } =
|
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId, claims } =
|
||||||
await getEmailContext({
|
await getEmailContext({
|
||||||
emailType: 'RECIPIENT',
|
emailType: 'RECIPIENT',
|
||||||
source: {
|
source: {
|
||||||
@@ -164,19 +164,22 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isRecipientEmailValidForSending(recipient)) {
|
if (isRecipientEmailValidForSending(recipient)) {
|
||||||
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
|
try {
|
||||||
|
await assertOrganisationRatesAndLimits({
|
||||||
if (!sendCheck.allowed) {
|
organisationId,
|
||||||
// TEMPORARY: silent drop on rate-limit hit. Job is consumed and NOT retried.
|
organisationClaim: claims,
|
||||||
|
type: 'email',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
io.logger.warn({
|
io.logger.warn({
|
||||||
msg: 'Recipient signing email dropped: org rate limit exceeded',
|
msg: 'Recipient signing email dropped: org rate limit exceeded',
|
||||||
organisationId,
|
organisationId,
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
reason: sendCheck.reason,
|
|
||||||
resetsAt: sendCheck.resetsAt,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Job is consumed and NOT retried.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mailer } from '@documenso/email/mailer';
|
|||||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||||
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
@@ -27,9 +27,9 @@ import { isDocumentCompleted } from '../../utils/document';
|
|||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed';
|
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
|
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
@@ -74,6 +74,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
|||||||
select: {
|
select: {
|
||||||
teamEmail: true,
|
teamEmail: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
organisation: {
|
||||||
|
select: {
|
||||||
|
organisationClaim: {
|
||||||
|
select: {
|
||||||
|
recipientCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -95,6 +104,19 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
|||||||
throw new Error('Can not send completed document');
|
throw new Error('Can not send completed document');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A recipientCount of 0 means unlimited recipients are allowed. Block resending
|
||||||
|
// when the document has more recipients than the organisation is allowed to send
|
||||||
|
// to, mirroring the check in `sendDocument`. This prevents bypassing the limit by
|
||||||
|
// adding recipients to an already-sent document and then resending.
|
||||||
|
const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount;
|
||||||
|
|
||||||
|
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
|
||||||
|
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
|
||||||
|
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh the expiresAt on each resent recipient.
|
// Refresh the expiresAt on each resent recipient.
|
||||||
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
||||||
|
|
||||||
@@ -128,7 +150,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
|||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId } =
|
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId, claims } =
|
||||||
await getEmailContext({
|
await getEmailContext({
|
||||||
emailType: 'RECIPIENT',
|
emailType: 'RECIPIENT',
|
||||||
source: {
|
source: {
|
||||||
@@ -138,6 +160,14 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
|||||||
meta: envelope.documentMeta,
|
meta: envelope.documentMeta,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Assert that there is enough quota to send the emails.
|
||||||
|
await assertOrganisationRatesAndLimits({
|
||||||
|
organisationId,
|
||||||
|
organisationClaim: claims,
|
||||||
|
count: recipientsToRemind.length,
|
||||||
|
type: 'email',
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipientsToRemind.map(async (recipient) => {
|
recipientsToRemind.map(async (recipient) => {
|
||||||
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
|
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
|
||||||
@@ -209,15 +239,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
|
|
||||||
|
|
||||||
if (!sendCheck.allowed) {
|
|
||||||
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
|
||||||
message: 'Organisation email send rate limit exceeded',
|
|
||||||
userMessage: 'Email send rate limit reached. Please try again in a few minutes.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email outside any transaction to avoid holding a connection
|
// Send email outside any transaction to avoid holding a connection
|
||||||
// open during network I/O.
|
// open during network I/O.
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
|
|||||||
@@ -84,6 +84,19 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
organisation: {
|
||||||
|
select: {
|
||||||
|
organisationClaim: {
|
||||||
|
select: {
|
||||||
|
recipientCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,6 +108,16 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
|||||||
throw new Error('Document has no recipients');
|
throw new Error('Document has no recipients');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A recipientCount of 0 means unlimited recipients are allowed.
|
||||||
|
const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount;
|
||||||
|
|
||||||
|
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
|
||||||
|
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
|
||||||
|
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(envelope.status)) {
|
if (isDocumentCompleted(envelope.status)) {
|
||||||
throw new Error('Can not send completed document');
|
throw new Error('Can not send completed document');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import {
|
|
||||||
recipientEmailRateLimit1d,
|
|
||||||
recipientEmailRateLimit5m,
|
|
||||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
|
||||||
|
|
||||||
type AssertOrgEmailSendAllowedOptions = {
|
|
||||||
organisationId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Result = { allowed: true } | { allowed: false; reason: '5m' | '1d'; resetsAt: Date };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TEMPORARY: rate-limit unsolicited recipient emails per organisation.
|
|
||||||
*
|
|
||||||
* Two layered windows: 100/5m and 1000/1d, both keyed to org id. Returns a
|
|
||||||
* result object so callers can choose to silently drop (job path) or throw
|
|
||||||
* (sync path).
|
|
||||||
*
|
|
||||||
* Remove this helper and all callers when the comprehensive abuse-prevention
|
|
||||||
* design lands. See .agents/plans/sharp-gold-wave-email-abuse-prevention.md
|
|
||||||
*/
|
|
||||||
export const assertOrgEmailSendAllowed = async (options: AssertOrgEmailSendAllowedOptions): Promise<Result> => {
|
|
||||||
// Self-hosted instances are not behind the SES cap.
|
|
||||||
if (!IS_BILLING_ENABLED()) {
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = `org:${options.organisationId}`;
|
|
||||||
|
|
||||||
const fiveMinResult = await recipientEmailRateLimit5m.check({ ip });
|
|
||||||
if (fiveMinResult.isLimited) {
|
|
||||||
return { allowed: false, reason: '5m', resetsAt: fiveMinResult.reset };
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyResult = await recipientEmailRateLimit1d.check({ ip });
|
|
||||||
if (dailyResult.isLimited) {
|
|
||||||
return { allowed: false, reason: '1d', resetsAt: dailyResult.reset };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { allowed: true };
|
|
||||||
};
|
|
||||||
@@ -22,7 +22,7 @@ export const createEmbeddingPresignToken = async ({
|
|||||||
}: CreateEmbeddingPresignTokenOptions) => {
|
}: CreateEmbeddingPresignTokenOptions) => {
|
||||||
try {
|
try {
|
||||||
// Validate the API token
|
// Validate the API token
|
||||||
const validatedToken = await getApiTokenByToken({ token: apiToken });
|
const validatedToken = await getApiTokenByToken({ token: apiToken, bypassRateLimit: true });
|
||||||
|
|
||||||
const now = DateTime.now();
|
const now = DateTime.now();
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { extractDerivedDocumentMeta } from '../../utils/document';
|
|||||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
@@ -155,6 +156,17 @@ export const createEnvelope = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce the organisation document-creation limit before doing any work.
|
||||||
|
// Only documents count towards the limit (templates are exempt).
|
||||||
|
if (type === EnvelopeType.DOCUMENT) {
|
||||||
|
await assertOrganisationRatesAndLimits({
|
||||||
|
organisationId: team.organisationId,
|
||||||
|
organisationClaim: team.organisation.organisationClaim,
|
||||||
|
type: 'document',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Verify that the folder exists and is associated with the team.
|
// Verify that the folder exists and is associated with the team.
|
||||||
if (folderId) {
|
if (folderId) {
|
||||||
const folder = await prisma.folder.findUnique({
|
const folder = await prisma.folder.findUnique({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { nanoid, prefixedId } from '../../universal/id';
|
|||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
export interface DuplicateEnvelopeOptions {
|
export interface DuplicateEnvelopeOptions {
|
||||||
@@ -25,7 +26,7 @@ export interface DuplicateEnvelopeOptions {
|
|||||||
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
|
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
|
||||||
const { duplicateAsTemplate = false, includeRecipients = true, includeFields = true } = overrides ?? {};
|
const { duplicateAsTemplate = false, includeRecipients = true, includeFields = true } = overrides ?? {};
|
||||||
|
|
||||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
|
||||||
id,
|
id,
|
||||||
type: null,
|
type: null,
|
||||||
userId,
|
userId,
|
||||||
@@ -83,6 +84,15 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
|||||||
|
|
||||||
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
|
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
|
||||||
|
|
||||||
|
// Enforce the organisation document-creation limit before creating the duplicate.
|
||||||
|
if (targetType === EnvelopeType.DOCUMENT) {
|
||||||
|
await assertOrganisationRatesAndLimits({
|
||||||
|
organisationId: team.organisationId,
|
||||||
|
type: 'document',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
|
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
|
||||||
targetType === EnvelopeType.DOCUMENT
|
targetType === EnvelopeType.DOCUMENT
|
||||||
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
|
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||||
|
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationMemberRole, OrganisationType, Prisma } from '@prisma/client';
|
import { OrganisationMemberRole, OrganisationType, Prisma, type SubscriptionClaim } from '@prisma/client';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||||
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
|
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import type { InternalClaim } from '../../types/subscription';
|
import type { InternalClaim } from '../../types/subscription';
|
||||||
import { INTERNAL_CLAIM_ID, internalClaims } from '../../types/subscription';
|
import { INTERNAL_CLAIM_ID } from '../../types/subscription';
|
||||||
import { generateDatabaseId, prefixedId } from '../../universal/id';
|
import { generateDatabaseId, prefixedId } from '../../universal/id';
|
||||||
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
|
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
|
||||||
import { createTeam } from '../team/create-team';
|
import { createTeam } from '../team/create-team';
|
||||||
@@ -151,12 +152,14 @@ export const createPersonalOrganisation = async ({
|
|||||||
inheritMembers = true,
|
inheritMembers = true,
|
||||||
type = OrganisationType.PERSONAL,
|
type = OrganisationType.PERSONAL,
|
||||||
}: CreatePersonalOrganisationOptions) => {
|
}: CreatePersonalOrganisationOptions) => {
|
||||||
|
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||||
|
|
||||||
const organisation = await createOrganisation({
|
const organisation = await createOrganisation({
|
||||||
name: 'Personal Organisation',
|
name: 'Personal Organisation',
|
||||||
userId,
|
userId,
|
||||||
url: orgUrl,
|
url: orgUrl,
|
||||||
type,
|
type,
|
||||||
claim: internalClaims[INTERNAL_CLAIM_ID.FREE],
|
claim: freeSubscriptionClaim,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@@ -184,15 +187,24 @@ export const createPersonalOrganisation = async ({
|
|||||||
return organisation;
|
return organisation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalClaim) => {
|
export const createOrganisationClaimUpsertData = (
|
||||||
|
subscriptionClaim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>,
|
||||||
|
) => {
|
||||||
// Done like this to ensure type errors are thrown if items are added.
|
// Done like this to ensure type errors are thrown if items are added.
|
||||||
const data: Omit<Prisma.SubscriptionClaimCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> = {
|
const data: Omit<Prisma.SubscriptionClaimCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> = {
|
||||||
flags: {
|
flags: {
|
||||||
...subscriptionClaim.flags,
|
...subscriptionClaim.flags,
|
||||||
},
|
},
|
||||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||||
|
recipientCount: subscriptionClaim.recipientCount,
|
||||||
teamCount: subscriptionClaim.teamCount,
|
teamCount: subscriptionClaim.teamCount,
|
||||||
memberCount: subscriptionClaim.memberCount,
|
memberCount: subscriptionClaim.memberCount,
|
||||||
|
documentRateLimits: subscriptionClaim.documentRateLimits ?? [],
|
||||||
|
documentQuota: subscriptionClaim.documentQuota,
|
||||||
|
emailRateLimits: subscriptionClaim.emailRateLimits ?? [],
|
||||||
|
emailQuota: subscriptionClaim.emailQuota,
|
||||||
|
apiRateLimits: subscriptionClaim.apiRateLimits ?? [],
|
||||||
|
apiQuota: subscriptionClaim.apiQuota,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,8 +2,20 @@ import { prisma } from '@documenso/prisma';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { hashString } from '../auth/hash';
|
import { hashString } from '../auth/hash';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
|
|
||||||
export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
type GetApiTokenByTokenOptions = {
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults to false.
|
||||||
|
*
|
||||||
|
* Will assert that the API request limit is not exceeded.
|
||||||
|
*/
|
||||||
|
bypassRateLimit?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: GetApiTokenByTokenOptions) => {
|
||||||
const hashedToken = hashString(token);
|
const hashedToken = hashString(token);
|
||||||
|
|
||||||
const apiToken = await prisma.apiToken.findFirst({
|
const apiToken = await prisma.apiToken.findFirst({
|
||||||
@@ -15,6 +27,7 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
|||||||
include: {
|
include: {
|
||||||
organisation: {
|
organisation: {
|
||||||
include: {
|
include: {
|
||||||
|
organisationClaim: true,
|
||||||
owner: {
|
owner: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -45,6 +58,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (apiToken.user?.disabled || apiToken.team.organisation.owner.disabled) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'User is disabled',
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (apiToken.expires && apiToken.expires < new Date()) {
|
if (apiToken.expires && apiToken.expires < new Date()) {
|
||||||
throw new AppError(AppErrorCode.EXPIRED_CODE, {
|
throw new AppError(AppErrorCode.EXPIRED_CODE, {
|
||||||
message: 'Expired token',
|
message: 'Expired token',
|
||||||
@@ -52,6 +72,15 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!bypassRateLimit) {
|
||||||
|
await assertOrganisationRatesAndLimits({
|
||||||
|
organisationId: apiToken.team.organisationId,
|
||||||
|
organisationClaim: apiToken.team.organisation.organisationClaim,
|
||||||
|
type: 'api',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle a silly choice from many moons ago
|
// Handle a silly choice from many moons ago
|
||||||
if (apiToken.team && !apiToken.user) {
|
if (apiToken.team && !apiToken.user) {
|
||||||
apiToken.user = apiToken.team.organisation.owner;
|
apiToken.user = apiToken.team.organisation.owner;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { OrganisationClaim } from '@prisma/client';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { ZRateLimitArraySchema } from '../../types/subscription';
|
||||||
|
import { checkMonthlyQuota } from './check-monthly-quota';
|
||||||
|
import { checkOrganisationRateLimits } from './check-organisation-rate-limits';
|
||||||
|
import type { LimitCounter } from './types';
|
||||||
|
|
||||||
|
type AssertOrganisationRatesAndLimitsOptions = {
|
||||||
|
organisationId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The organisation claim to use. If not provided, it will be loaded from the database.
|
||||||
|
*/
|
||||||
|
organisationClaim?: OrganisationClaim;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Units to reserve. Must be >= 0.
|
||||||
|
*/
|
||||||
|
count: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of rate limit to assert.
|
||||||
|
*/
|
||||||
|
type: LimitCounter;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertOrganisationRatesAndLimits = async (
|
||||||
|
opts: AssertOrganisationRatesAndLimitsOptions,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { organisationClaim, count } = opts;
|
||||||
|
|
||||||
|
// Nothing to reserve, treat as a no-op.
|
||||||
|
if (count === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count < 0) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Count must be greater than or equal to 0',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organisationClaim) {
|
||||||
|
const organisation = await prisma.organisation.findUniqueOrThrow({
|
||||||
|
where: { id: opts.organisationId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
organisationClaim: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
organisationClaim = organisation.organisationClaim;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rateLimits, quota } = match(opts.type)
|
||||||
|
.with('api', () => ({
|
||||||
|
rateLimits: ZRateLimitArraySchema.parse(organisationClaim.apiRateLimits),
|
||||||
|
quota: organisationClaim.apiQuota,
|
||||||
|
}))
|
||||||
|
.with('document', () => ({
|
||||||
|
rateLimits: ZRateLimitArraySchema.parse(organisationClaim.documentRateLimits),
|
||||||
|
quota: organisationClaim.documentQuota,
|
||||||
|
}))
|
||||||
|
.with('email', () => ({
|
||||||
|
rateLimits: ZRateLimitArraySchema.parse(organisationClaim.emailRateLimits),
|
||||||
|
quota: organisationClaim.emailQuota,
|
||||||
|
}))
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
await checkOrganisationRateLimits({
|
||||||
|
organisationId: opts.organisationId,
|
||||||
|
counter: opts.type,
|
||||||
|
entries: rateLimits,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
|
||||||
|
await checkMonthlyQuota({
|
||||||
|
organisationId: opts.organisationId,
|
||||||
|
counter: opts.type,
|
||||||
|
quota,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { jobsClient } from '../../jobs/client';
|
||||||
|
import { generateDatabaseId } from '../../universal/id';
|
||||||
|
import { currentMonthlyPeriod } from './current-monthly-period';
|
||||||
|
import type { LimitCounter } from './types';
|
||||||
|
|
||||||
|
type CheckMonthlyQuotaOptions = {
|
||||||
|
organisationId: string;
|
||||||
|
counter: LimitCounter;
|
||||||
|
quota: number | null;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COUNTER_COLUMN = {
|
||||||
|
document: 'documentCount',
|
||||||
|
email: 'emailCount',
|
||||||
|
api: 'apiCount',
|
||||||
|
} as const satisfies Record<LimitCounter, string>;
|
||||||
|
|
||||||
|
export const checkMonthlyQuota = async (opts: CheckMonthlyQuotaOptions): Promise<void> => {
|
||||||
|
if (opts.quota === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.quota === 0) {
|
||||||
|
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
||||||
|
message:
|
||||||
|
'Your request could not be completed at this time due to your account exceeding the fair use limits of your current plan. Please contact support.',
|
||||||
|
// Not tossing headers here to avoid confusion, this isn't rate limits.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = currentMonthlyPeriod();
|
||||||
|
const column = COUNTER_COLUMN[opts.counter];
|
||||||
|
|
||||||
|
const latestMonthlyStat = await prisma.organisationMonthlyStat.upsert({
|
||||||
|
where: {
|
||||||
|
organisationId_period: {
|
||||||
|
organisationId: opts.organisationId,
|
||||||
|
period,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
[column]: { increment: opts.count },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: generateDatabaseId('org_monthly_stat'),
|
||||||
|
organisationId: opts.organisationId,
|
||||||
|
period,
|
||||||
|
[column]: opts.count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCount = latestMonthlyStat[column];
|
||||||
|
const previousCount = newCount - opts.count;
|
||||||
|
|
||||||
|
const isOverQuota = newCount > opts.quota;
|
||||||
|
|
||||||
|
// Only notify on the single request that crossed the threshold: the count was
|
||||||
|
// at/under quota before this request and over it after. Because the DB
|
||||||
|
// serializes the atomic increment, the post-increment values are distinct and
|
||||||
|
// monotonic, so exactly one request's (previousCount, newCount] interval
|
||||||
|
// contains the quota boundary — guaranteeing the notification fires once.
|
||||||
|
const didCrossQuota = isOverQuota && previousCount <= opts.quota;
|
||||||
|
|
||||||
|
if (didCrossQuota) {
|
||||||
|
await jobsClient
|
||||||
|
.triggerJob({
|
||||||
|
name: 'send.organisation-limit-exceeded.email',
|
||||||
|
payload: {
|
||||||
|
organisationId: opts.organisationId,
|
||||||
|
counter: opts.counter,
|
||||||
|
kind: 'quota',
|
||||||
|
period,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error({
|
||||||
|
msg: 'Failed to send organisation limit exceeded email',
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do nothing.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOverQuota) {
|
||||||
|
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
||||||
|
message:
|
||||||
|
'Your request could not be completed at this time due to your account exceeding the fair use limits of your current plan. Please contact support.',
|
||||||
|
// Not tossing headers here to avoid confusion, this isn't rate limits.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import type { TRateLimitArray } from '../../types/subscription';
|
||||||
|
import { createRateLimit } from './rate-limit';
|
||||||
|
import type { LimitCounter, RateLimitEntry } from './types';
|
||||||
|
|
||||||
|
type CheckOrganisationRateLimitsOptions = {
|
||||||
|
organisationId: string;
|
||||||
|
counter: LimitCounter;
|
||||||
|
entries: TRateLimitArray;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce an organisation's windowed rate limits.
|
||||||
|
*
|
||||||
|
* Each window is checked against a bucketed counter keyed to the organisation.
|
||||||
|
* `count` units are consumed per check (e.g. a batch of reminder emails).
|
||||||
|
*/
|
||||||
|
export const checkOrganisationRateLimits = async (opts: CheckOrganisationRateLimitsOptions): Promise<void> => {
|
||||||
|
for (const entry of opts.entries) {
|
||||||
|
// Zod has validated the window against /^\d+[smhd]$/, which matches WindowStr.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const window = entry.window as RateLimitEntry['window'];
|
||||||
|
|
||||||
|
const limiter = createRateLimit({
|
||||||
|
action: `org.${opts.counter}.${window}`,
|
||||||
|
max: entry.max,
|
||||||
|
window,
|
||||||
|
});
|
||||||
|
|
||||||
|
// There's no real IP, so we just use the organisation ID as a key.
|
||||||
|
const result = await limiter.check({ ip: `org:${opts.organisationId}`, count: opts.count });
|
||||||
|
|
||||||
|
if (result.isLimited) {
|
||||||
|
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
||||||
|
// Note: Update the organisation-rate-limits.spec.ts message if you change this value.
|
||||||
|
// Used in the test to differentiate between the global and organisation rate limits.
|
||||||
|
message: 'Too many requests, please try again later. Contact support if you require higher limits.',
|
||||||
|
headers: {
|
||||||
|
'X-RateLimit-Limit': String(entry.max),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
'X-RateLimit-Reset': String(Math.ceil(result.reset.getTime() / 1000)),
|
||||||
|
'Retry-After': String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** Current UTC calendar month as `YYYY-MM`. */
|
||||||
|
export const currentMonthlyPeriod = (): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${now.getUTCFullYear()}-${month}`;
|
||||||
|
};
|
||||||
@@ -15,6 +15,8 @@ type RateLimitConfig = {
|
|||||||
type CheckParams = {
|
type CheckParams = {
|
||||||
ip: string;
|
ip: string;
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
|
/** Number of units to consume in this check. Defaults to 1. */
|
||||||
|
count?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RateLimitCheckResult = {
|
export type RateLimitCheckResult = {
|
||||||
@@ -66,6 +68,7 @@ export const createRateLimit = (config: RateLimitConfig) => {
|
|||||||
const bucket = getBucket(windowMs);
|
const bucket = getBucket(windowMs);
|
||||||
const reset = new Date(bucket.getTime() + windowMs);
|
const reset = new Date(bucket.getTime() + windowMs);
|
||||||
const ipLimit = config.globalMax ?? config.max;
|
const ipLimit = config.globalMax ?? config.max;
|
||||||
|
const count = params.count ?? 1;
|
||||||
|
|
||||||
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
|
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
|
||||||
return {
|
return {
|
||||||
@@ -90,10 +93,10 @@ export const createRateLimit = (config: RateLimitConfig) => {
|
|||||||
key: `ip:${params.ip}`,
|
key: `ip:${params.ip}`,
|
||||||
action: config.action,
|
action: config.action,
|
||||||
bucket,
|
bucket,
|
||||||
count: 1,
|
count,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
count: { increment: 1 },
|
count: { increment: count },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,10 +139,10 @@ export const createRateLimit = (config: RateLimitConfig) => {
|
|||||||
key: `id:${params.identifier}`,
|
key: `id:${params.identifier}`,
|
||||||
action: config.action,
|
action: config.action,
|
||||||
bucket,
|
bucket,
|
||||||
count: 1,
|
count,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
count: { increment: 1 },
|
count: { increment: count },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -97,17 +97,3 @@ export const fileUploadRateLimit = createRateLimit({
|
|||||||
max: 20,
|
max: 20,
|
||||||
window: '1m',
|
window: '1m',
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Recipient email send (TEMPORARY: per-org abuse-prevention stopgap) ----
|
|
||||||
|
|
||||||
export const recipientEmailRateLimit5m = createRateLimit({
|
|
||||||
action: 'email.send.recipient.5m',
|
|
||||||
max: 100,
|
|
||||||
window: '5m',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const recipientEmailRateLimit1d = createRateLimit({
|
|
||||||
action: 'email.send.recipient.1d',
|
|
||||||
max: 1500,
|
|
||||||
window: '1d',
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||||
|
|
||||||
|
export type LimitCounter = 'api' | 'document' | 'email';
|
||||||
|
|
||||||
|
export type LimitOptions = {
|
||||||
|
organisationId: string;
|
||||||
|
organisationClaim?: OrganisationClaim;
|
||||||
|
monthlyStat?: OrganisationMonthlyStat;
|
||||||
|
|
||||||
|
// Units to reserve. Default 1. Must be >= 1.
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RateLimitEntry = {
|
||||||
|
window: `${number}${'s' | 'm' | 'h' | 'd'}`;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { SubscriptionClaim } from '@prisma/client';
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
|
||||||
|
export const getSubscriptionClaim = async (
|
||||||
|
claimId: string,
|
||||||
|
): Promise<Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>> => {
|
||||||
|
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||||
|
where: { id: claimId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscriptionClaim) {
|
||||||
|
// Temporary fallback for free claim so we don't break self-hosters who somehow removed it
|
||||||
|
// from the database.
|
||||||
|
if (claimId === INTERNAL_CLAIM_ID.FREE) {
|
||||||
|
return internalClaims[INTERNAL_CLAIM_ID.FREE];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: `Subscription claim ${claimId} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscriptionClaim;
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
|||||||
import { sendDocument } from '../document/send-document';
|
import { sendDocument } from '../document/send-document';
|
||||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||||
import { incrementDocumentId } from '../envelope/increment-id';
|
import { incrementDocumentId } from '../envelope/increment-id';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
@@ -115,6 +116,20 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
organisationId: true,
|
||||||
|
organisation: {
|
||||||
|
select: {
|
||||||
|
organisationClaim: {
|
||||||
|
select: {
|
||||||
|
recipientCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,6 +196,21 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
const nonDirectTemplateRecipients = directTemplateEnvelope.recipients.filter(
|
const nonDirectTemplateRecipients = directTemplateEnvelope.recipients.filter(
|
||||||
(recipient) => recipient.id !== directTemplateRecipient.id,
|
(recipient) => recipient.id !== directTemplateRecipient.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The resulting document contains every non-direct template recipient plus the
|
||||||
|
// direct recipient that is signing now. A recipientCount of 0 means unlimited.
|
||||||
|
// This mirrors the check in `sendDocument`, but must be done here because this
|
||||||
|
// flow creates the document directly in PENDING and swallows `sendDocument` errors.
|
||||||
|
const maximumRecipientCount = directTemplateEnvelope.team.organisation.organisationClaim.recipientCount;
|
||||||
|
const resultingRecipientCount = nonDirectTemplateRecipients.length + 1;
|
||||||
|
|
||||||
|
if (maximumRecipientCount > 0 && resultingRecipientCount > maximumRecipientCount) {
|
||||||
|
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
|
||||||
|
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta);
|
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta);
|
||||||
|
|
||||||
// Associate, validate and map to a query every direct template recipient field with the provided fields.
|
// Associate, validate and map to a query every direct template recipient field with the provided fields.
|
||||||
@@ -269,6 +299,13 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
|
|
||||||
const directTemplateSignatureFields = createDirectRecipientFieldArgs.filter(({ signature }) => signature !== null);
|
const directTemplateSignatureFields = createDirectRecipientFieldArgs.filter(({ signature }) => signature !== null);
|
||||||
|
|
||||||
|
// Enforce the organisation document-creation limit before creating the document.
|
||||||
|
await assertOrganisationRatesAndLimits({
|
||||||
|
organisationId: directTemplateEnvelope.team.organisationId,
|
||||||
|
type: 'document',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const initialRequestTime = new Date();
|
const initialRequestTime = new Date();
|
||||||
|
|
||||||
// Key = original envelope item ID
|
// Key = original envelope item ID
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { buildTeamWhereQuery } from '../../utils/teams';
|
|||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { incrementDocumentId } from '../envelope/increment-id';
|
import { incrementDocumentId } from '../envelope/increment-id';
|
||||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
|
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
|
||||||
@@ -503,6 +504,13 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Enforce the organisation document-creation limit before creating the document.
|
||||||
|
await assertOrganisationRatesAndLimits({
|
||||||
|
organisationId: callerTeam.organisationId,
|
||||||
|
type: 'document',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const incrementedDocumentId = await incrementDocumentId();
|
const incrementedDocumentId = await incrementDocumentId();
|
||||||
|
|
||||||
const documentMeta = await prisma.documentMeta.create({
|
const documentMeta = await prisma.documentMeta.create({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const validateApiToken = async ({ authorization }: ValidateApiTokenOption
|
|||||||
throw new Error('Missing API token');
|
throw new Error('Missing API token');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getApiTokenByToken({ token });
|
return await getApiTokenByToken({ token, bypassRateLimit: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to validate API token`);
|
throw new Error(`Failed to validate API token`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import { ZOrganisationNameSchema } from '@documenso/trpc/server/organisation-rou
|
|||||||
import type { SubscriptionClaim } from '@prisma/client';
|
import type { SubscriptionClaim } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit window schema.
|
||||||
|
*
|
||||||
|
* Example: "5m", "1h", "1d"
|
||||||
|
*/
|
||||||
|
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
|
||||||
|
|
||||||
|
export const ZRateLimitArraySchema = z.array(
|
||||||
|
z.object({
|
||||||
|
window: ZRateLimitWindowSchema,
|
||||||
|
max: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* README:
|
* README:
|
||||||
* - If you update this you MUST update the `backport-subscription-claims` schema as well.
|
* - If you update this you MUST update the `backport-subscription-claims` schema as well.
|
||||||
@@ -123,15 +139,33 @@ export type InternalClaims = {
|
|||||||
[key in INTERNAL_CLAIM_ID]: InternalClaim;
|
[key in INTERNAL_CLAIM_ID]: InternalClaim;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: THIS NEEDS A REWORK
|
||||||
|
*
|
||||||
|
* Only the values within "free" claim (flags, etc) are directly used, the rest are taken
|
||||||
|
* from the actual SubscriptionClaim in the database.
|
||||||
|
*
|
||||||
|
* We need to remove all the content besides id/name and fetch free from the database.
|
||||||
|
*/
|
||||||
export const internalClaims: InternalClaims = {
|
export const internalClaims: InternalClaims = {
|
||||||
|
/**
|
||||||
|
* Free plan has no rates and quotas since this may break self-hosters.
|
||||||
|
*/
|
||||||
[INTERNAL_CLAIM_ID.FREE]: {
|
[INTERNAL_CLAIM_ID.FREE]: {
|
||||||
id: INTERNAL_CLAIM_ID.FREE,
|
id: INTERNAL_CLAIM_ID.FREE,
|
||||||
name: 'Free',
|
name: 'Free',
|
||||||
teamCount: 1,
|
teamCount: 1,
|
||||||
memberCount: 1,
|
memberCount: 1,
|
||||||
envelopeItemCount: 5,
|
envelopeItemCount: 5,
|
||||||
|
recipientCount: 0,
|
||||||
locked: true,
|
locked: true,
|
||||||
flags: {},
|
flags: {},
|
||||||
|
documentRateLimits: [],
|
||||||
|
documentQuota: null,
|
||||||
|
emailRateLimits: [],
|
||||||
|
emailQuota: null,
|
||||||
|
apiRateLimits: [],
|
||||||
|
apiQuota: null,
|
||||||
},
|
},
|
||||||
[INTERNAL_CLAIM_ID.INDIVIDUAL]: {
|
[INTERNAL_CLAIM_ID.INDIVIDUAL]: {
|
||||||
id: INTERNAL_CLAIM_ID.INDIVIDUAL,
|
id: INTERNAL_CLAIM_ID.INDIVIDUAL,
|
||||||
@@ -139,11 +173,18 @@ export const internalClaims: InternalClaims = {
|
|||||||
teamCount: 1,
|
teamCount: 1,
|
||||||
memberCount: 1,
|
memberCount: 1,
|
||||||
envelopeItemCount: 5,
|
envelopeItemCount: 5,
|
||||||
|
recipientCount: 0,
|
||||||
locked: true,
|
locked: true,
|
||||||
flags: {
|
flags: {
|
||||||
unlimitedDocuments: true,
|
unlimitedDocuments: true,
|
||||||
signingReminders: true,
|
signingReminders: true,
|
||||||
},
|
},
|
||||||
|
documentRateLimits: [],
|
||||||
|
documentQuota: null,
|
||||||
|
emailRateLimits: [],
|
||||||
|
emailQuota: null,
|
||||||
|
apiRateLimits: [],
|
||||||
|
apiQuota: null,
|
||||||
},
|
},
|
||||||
[INTERNAL_CLAIM_ID.TEAM]: {
|
[INTERNAL_CLAIM_ID.TEAM]: {
|
||||||
id: INTERNAL_CLAIM_ID.TEAM,
|
id: INTERNAL_CLAIM_ID.TEAM,
|
||||||
@@ -151,6 +192,7 @@ export const internalClaims: InternalClaims = {
|
|||||||
teamCount: 1,
|
teamCount: 1,
|
||||||
memberCount: 5,
|
memberCount: 5,
|
||||||
envelopeItemCount: 5,
|
envelopeItemCount: 5,
|
||||||
|
recipientCount: 0,
|
||||||
locked: true,
|
locked: true,
|
||||||
flags: {
|
flags: {
|
||||||
unlimitedDocuments: true,
|
unlimitedDocuments: true,
|
||||||
@@ -158,6 +200,12 @@ export const internalClaims: InternalClaims = {
|
|||||||
embedSigning: true,
|
embedSigning: true,
|
||||||
signingReminders: true,
|
signingReminders: true,
|
||||||
},
|
},
|
||||||
|
documentRateLimits: [],
|
||||||
|
documentQuota: null,
|
||||||
|
emailRateLimits: [],
|
||||||
|
emailQuota: null,
|
||||||
|
apiRateLimits: [],
|
||||||
|
apiQuota: null,
|
||||||
},
|
},
|
||||||
[INTERNAL_CLAIM_ID.PLATFORM]: {
|
[INTERNAL_CLAIM_ID.PLATFORM]: {
|
||||||
id: INTERNAL_CLAIM_ID.PLATFORM,
|
id: INTERNAL_CLAIM_ID.PLATFORM,
|
||||||
@@ -165,6 +213,7 @@ export const internalClaims: InternalClaims = {
|
|||||||
teamCount: 1,
|
teamCount: 1,
|
||||||
memberCount: 0,
|
memberCount: 0,
|
||||||
envelopeItemCount: 10,
|
envelopeItemCount: 10,
|
||||||
|
recipientCount: 0,
|
||||||
locked: true,
|
locked: true,
|
||||||
flags: {
|
flags: {
|
||||||
unlimitedDocuments: true,
|
unlimitedDocuments: true,
|
||||||
@@ -177,6 +226,12 @@ export const internalClaims: InternalClaims = {
|
|||||||
embedSigningWhiteLabel: true,
|
embedSigningWhiteLabel: true,
|
||||||
signingReminders: true,
|
signingReminders: true,
|
||||||
},
|
},
|
||||||
|
documentRateLimits: [],
|
||||||
|
documentQuota: null,
|
||||||
|
emailRateLimits: [],
|
||||||
|
emailQuota: null,
|
||||||
|
apiRateLimits: [],
|
||||||
|
apiQuota: null,
|
||||||
},
|
},
|
||||||
[INTERNAL_CLAIM_ID.ENTERPRISE]: {
|
[INTERNAL_CLAIM_ID.ENTERPRISE]: {
|
||||||
id: INTERNAL_CLAIM_ID.ENTERPRISE,
|
id: INTERNAL_CLAIM_ID.ENTERPRISE,
|
||||||
@@ -184,6 +239,7 @@ export const internalClaims: InternalClaims = {
|
|||||||
teamCount: 0,
|
teamCount: 0,
|
||||||
memberCount: 0,
|
memberCount: 0,
|
||||||
envelopeItemCount: 10,
|
envelopeItemCount: 10,
|
||||||
|
recipientCount: 0,
|
||||||
locked: true,
|
locked: true,
|
||||||
flags: {
|
flags: {
|
||||||
unlimitedDocuments: true,
|
unlimitedDocuments: true,
|
||||||
@@ -198,6 +254,12 @@ export const internalClaims: InternalClaims = {
|
|||||||
authenticationPortal: true,
|
authenticationPortal: true,
|
||||||
signingReminders: true,
|
signingReminders: true,
|
||||||
},
|
},
|
||||||
|
documentRateLimits: [],
|
||||||
|
documentQuota: null,
|
||||||
|
emailRateLimits: [],
|
||||||
|
emailQuota: null,
|
||||||
|
apiRateLimits: [],
|
||||||
|
apiQuota: null,
|
||||||
},
|
},
|
||||||
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {
|
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {
|
||||||
id: INTERNAL_CLAIM_ID.EARLY_ADOPTER,
|
id: INTERNAL_CLAIM_ID.EARLY_ADOPTER,
|
||||||
@@ -205,6 +267,7 @@ export const internalClaims: InternalClaims = {
|
|||||||
teamCount: 0,
|
teamCount: 0,
|
||||||
memberCount: 0,
|
memberCount: 0,
|
||||||
envelopeItemCount: 5,
|
envelopeItemCount: 5,
|
||||||
|
recipientCount: 0,
|
||||||
locked: true,
|
locked: true,
|
||||||
flags: {
|
flags: {
|
||||||
unlimitedDocuments: true,
|
unlimitedDocuments: true,
|
||||||
@@ -214,6 +277,12 @@ export const internalClaims: InternalClaims = {
|
|||||||
embedSigningWhiteLabel: true,
|
embedSigningWhiteLabel: true,
|
||||||
signingReminders: true,
|
signingReminders: true,
|
||||||
},
|
},
|
||||||
|
documentRateLimits: [],
|
||||||
|
documentQuota: null,
|
||||||
|
emailRateLimits: [],
|
||||||
|
emailQuota: null,
|
||||||
|
apiRateLimits: [],
|
||||||
|
apiQuota: null,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type DatabaseIdPrefix =
|
|||||||
| 'email_domain'
|
| 'email_domain'
|
||||||
| 'org'
|
| 'org'
|
||||||
| 'org_email'
|
| 'org_email'
|
||||||
|
| 'org_monthly_stat'
|
||||||
| 'org_claim'
|
| 'org_claim'
|
||||||
| 'org_group'
|
| 'org_group'
|
||||||
| 'org_sso'
|
| 'org_sso'
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from '@documenso/ee/server-only/limits/constants';
|
import {
|
||||||
|
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||||
|
DEFAULT_RECIPIENT_COUNT,
|
||||||
|
} from '@documenso/ee/server-only/limits/constants';
|
||||||
import type { SubscriptionClaim } from '@prisma/client';
|
import type { SubscriptionClaim } from '@prisma/client';
|
||||||
|
|
||||||
export const generateDefaultSubscriptionClaim = (): Omit<
|
export const generateDefaultSubscriptionClaim = (): Omit<
|
||||||
@@ -10,7 +13,15 @@ export const generateDefaultSubscriptionClaim = (): Omit<
|
|||||||
teamCount: 1,
|
teamCount: 1,
|
||||||
memberCount: 1,
|
memberCount: 1,
|
||||||
envelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
envelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||||
|
recipientCount: DEFAULT_RECIPIENT_COUNT,
|
||||||
locked: false,
|
locked: false,
|
||||||
flags: {},
|
flags: {},
|
||||||
|
|
||||||
|
documentRateLimits: [],
|
||||||
|
documentQuota: null,
|
||||||
|
emailRateLimits: [],
|
||||||
|
emailQuota: null,
|
||||||
|
apiRateLimits: [],
|
||||||
|
apiQuota: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- AlterTable
|
||||||
|
-- Add the new columns with temporary defaults to backfill existing rows, then
|
||||||
|
-- drop the defaults so the columns match the schema (required, no default).
|
||||||
|
ALTER TABLE "OrganisationClaim" ADD COLUMN "apiQuota" INTEGER,
|
||||||
|
ADD COLUMN "apiRateLimits" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN "documentQuota" INTEGER,
|
||||||
|
ADD COLUMN "documentRateLimits" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN "emailQuota" INTEGER,
|
||||||
|
ADD COLUMN "emailRateLimits" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN "recipientCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE "OrganisationClaim" ALTER COLUMN "apiRateLimits" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "documentRateLimits" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "emailRateLimits" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "recipientCount" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SubscriptionClaim" ADD COLUMN "apiQuota" INTEGER,
|
||||||
|
ADD COLUMN "apiRateLimits" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN "documentQuota" INTEGER,
|
||||||
|
ADD COLUMN "documentRateLimits" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN "emailQuota" INTEGER,
|
||||||
|
ADD COLUMN "emailRateLimits" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN "recipientCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE "SubscriptionClaim" ALTER COLUMN "apiRateLimits" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "documentRateLimits" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "emailRateLimits" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "recipientCount" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrganisationMonthlyStat" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"organisationId" TEXT NOT NULL,
|
||||||
|
"period" TEXT NOT NULL,
|
||||||
|
"documentCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"emailCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"apiCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "OrganisationMonthlyStat_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrganisationMonthlyStat_organisationId_idx" ON "OrganisationMonthlyStat"("organisationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "OrganisationMonthlyStat_organisationId_period_key" ON "OrganisationMonthlyStat"("organisationId", "period");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrganisationMonthlyStat" ADD CONSTRAINT "OrganisationMonthlyStat_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -256,7 +256,7 @@ model Subscription {
|
|||||||
@@index([organisationId])
|
@@index([organisationId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @zod.import(["import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';"])
|
/// @zod.import(["import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';"])
|
||||||
model SubscriptionClaim {
|
model SubscriptionClaim {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -268,11 +268,21 @@ model SubscriptionClaim {
|
|||||||
teamCount Int
|
teamCount Int
|
||||||
memberCount Int
|
memberCount Int
|
||||||
envelopeItemCount Int
|
envelopeItemCount Int
|
||||||
|
recipientCount Int
|
||||||
|
|
||||||
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
|
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
|
||||||
|
|
||||||
|
documentRateLimits Json /// [RateLimitArray] @zod.custom.use(ZRateLimitArraySchema)
|
||||||
|
documentQuota Int?
|
||||||
|
|
||||||
|
emailRateLimits Json /// [RateLimitArray] @zod.custom.use(ZRateLimitArraySchema)
|
||||||
|
emailQuota Int?
|
||||||
|
|
||||||
|
apiRateLimits Json /// [RateLimitArray] @zod.custom.use(ZRateLimitArraySchema)
|
||||||
|
apiQuota Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @zod.import(["import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';"])
|
/// @zod.import(["import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';"])
|
||||||
model OrganisationClaim {
|
model OrganisationClaim {
|
||||||
id String @id
|
id String @id
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -284,8 +294,37 @@ model OrganisationClaim {
|
|||||||
teamCount Int
|
teamCount Int
|
||||||
memberCount Int
|
memberCount Int
|
||||||
envelopeItemCount Int
|
envelopeItemCount Int
|
||||||
|
recipientCount Int
|
||||||
|
|
||||||
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
|
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
|
||||||
|
|
||||||
|
documentRateLimits Json /// [RateLimitArray] @zod.custom.use(ZRateLimitArraySchema)
|
||||||
|
documentQuota Int?
|
||||||
|
|
||||||
|
emailRateLimits Json /// [RateLimitArray] @zod.custom.use(ZRateLimitArraySchema)
|
||||||
|
emailQuota Int?
|
||||||
|
|
||||||
|
apiRateLimits Json /// [RateLimitArray] @zod.custom.use(ZRateLimitArraySchema)
|
||||||
|
apiQuota Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrganisationMonthlyStat {
|
||||||
|
id String @id
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
organisationId String
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// UTC calendar month in `YYYY-MM` form, e.g. "2026-05".
|
||||||
|
period String
|
||||||
|
|
||||||
|
documentCount Int @default(0)
|
||||||
|
emailCount Int @default(0)
|
||||||
|
apiCount Int @default(0)
|
||||||
|
|
||||||
|
@@unique([organisationId, period])
|
||||||
|
@@index([organisationId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -714,6 +753,8 @@ model Organisation {
|
|||||||
emailDomains EmailDomain[]
|
emailDomains EmailDomain[]
|
||||||
organisationEmails OrganisationEmail[]
|
organisationEmails OrganisationEmail[]
|
||||||
|
|
||||||
|
monthlyStats OrganisationMonthlyStat[]
|
||||||
|
|
||||||
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
|
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
ownerUserId Int
|
ownerUserId Int
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||||
import { internalClaims } from '@documenso/lib/types/subscription';
|
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||||
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { OrganisationType } from '@prisma/client';
|
import { OrganisationType } from '@prisma/client';
|
||||||
|
|
||||||
import { adminProcedure } from '../trpc';
|
import { adminProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
ZCreateAdminOrganisationRequestSchema,
|
ZCreateAdminOrganisationRequestSchema,
|
||||||
@@ -20,11 +20,13 @@ export const createAdminOrganisationRoute = adminProcedure
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||||
|
|
||||||
const organisation = await createOrganisation({
|
const organisation = await createOrganisation({
|
||||||
userId: ownerUserId,
|
userId: ownerUserId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
type: OrganisationType.ORGANISATION,
|
type: OrganisationType.ORGANISATION,
|
||||||
claim: internalClaims.free,
|
claim: freeSubscriptionClaim,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,7 +10,20 @@ export const createSubscriptionClaimRoute = adminProcedure
|
|||||||
.input(ZCreateSubscriptionClaimRequestSchema)
|
.input(ZCreateSubscriptionClaimRequestSchema)
|
||||||
.output(ZCreateSubscriptionClaimResponseSchema)
|
.output(ZCreateSubscriptionClaimResponseSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { name, teamCount, memberCount, envelopeItemCount, flags } = input;
|
const {
|
||||||
|
name,
|
||||||
|
teamCount,
|
||||||
|
memberCount,
|
||||||
|
envelopeItemCount,
|
||||||
|
recipientCount,
|
||||||
|
flags,
|
||||||
|
documentRateLimits,
|
||||||
|
documentQuota,
|
||||||
|
emailRateLimits,
|
||||||
|
emailQuota,
|
||||||
|
apiRateLimits,
|
||||||
|
apiQuota,
|
||||||
|
} = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input,
|
input,
|
||||||
@@ -21,8 +34,15 @@ export const createSubscriptionClaimRoute = adminProcedure
|
|||||||
name,
|
name,
|
||||||
teamCount,
|
teamCount,
|
||||||
envelopeItemCount,
|
envelopeItemCount,
|
||||||
|
recipientCount,
|
||||||
memberCount,
|
memberCount,
|
||||||
flags,
|
flags,
|
||||||
|
documentRateLimits,
|
||||||
|
documentQuota,
|
||||||
|
emailRateLimits,
|
||||||
|
emailQuota,
|
||||||
|
apiRateLimits,
|
||||||
|
apiQuota,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';
|
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZCreateSubscriptionClaimRequestSchema = z.object({
|
export const ZCreateSubscriptionClaimRequestSchema = z.object({
|
||||||
@@ -6,7 +6,17 @@ export const ZCreateSubscriptionClaimRequestSchema = z.object({
|
|||||||
teamCount: z.number().int().min(0),
|
teamCount: z.number().int().min(0),
|
||||||
memberCount: z.number().int().min(0),
|
memberCount: z.number().int().min(0),
|
||||||
envelopeItemCount: z.number().int().min(1),
|
envelopeItemCount: z.number().int().min(1),
|
||||||
|
recipientCount: z.number().int().min(0),
|
||||||
flags: ZClaimFlagsSchema,
|
flags: ZClaimFlagsSchema,
|
||||||
|
|
||||||
|
documentRateLimits: ZRateLimitArraySchema,
|
||||||
|
documentQuota: z.number().int().min(0).nullable(),
|
||||||
|
|
||||||
|
emailRateLimits: ZRateLimitArraySchema,
|
||||||
|
emailQuota: z.number().int().min(0).nullable(),
|
||||||
|
|
||||||
|
apiRateLimits: ZRateLimitArraySchema,
|
||||||
|
apiQuota: z.number().int().min(0).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateSubscriptionClaimResponseSchema = z.void();
|
export const ZCreateSubscriptionClaimResponseSchema = z.void();
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ export const ZFindSubscriptionClaimsResponseSchema = ZFindResultResponse.extend(
|
|||||||
teamCount: true,
|
teamCount: true,
|
||||||
memberCount: true,
|
memberCount: true,
|
||||||
envelopeItemCount: true,
|
envelopeItemCount: true,
|
||||||
|
recipientCount: true,
|
||||||
locked: true,
|
locked: true,
|
||||||
flags: true,
|
flags: true,
|
||||||
|
documentRateLimits: true,
|
||||||
|
documentQuota: true,
|
||||||
|
emailRateLimits: true,
|
||||||
|
emailQuota: true,
|
||||||
|
apiRateLimits: true,
|
||||||
|
apiQuota: true,
|
||||||
}).array(),
|
}).array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp
|
|||||||
organisationClaim: true,
|
organisationClaim: true,
|
||||||
organisationGlobalSettings: true,
|
organisationGlobalSettings: true,
|
||||||
teams: true,
|
teams: true,
|
||||||
|
monthlyStats: {
|
||||||
|
orderBy: {
|
||||||
|
period: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
members: {
|
members: {
|
||||||
include: {
|
include: {
|
||||||
organisationGroupMembers: {
|
organisationGroupMembers: {
|
||||||
@@ -63,5 +68,7 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return organisation;
|
return {
|
||||||
|
...organisation,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/mo
|
|||||||
import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema';
|
import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema';
|
||||||
import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
|
import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
|
||||||
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
|
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
|
||||||
|
import OrganisationMonthlyStatSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMonthlyStatSchema';
|
||||||
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
|
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
|
||||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||||
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||||
@@ -46,6 +47,14 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
|
|||||||
}).array(),
|
}).array(),
|
||||||
subscription: SubscriptionSchema.nullable(),
|
subscription: SubscriptionSchema.nullable(),
|
||||||
organisationClaim: OrganisationClaimSchema,
|
organisationClaim: OrganisationClaimSchema,
|
||||||
|
monthlyStats: z.array(
|
||||||
|
OrganisationMonthlyStatSchema.pick({
|
||||||
|
period: true,
|
||||||
|
documentCount: true,
|
||||||
|
emailCount: true,
|
||||||
|
apiCount: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TGetAdminOrganisationResponse = z.infer<typeof ZGetAdminOrganisationResponseSchema>;
|
export type TGetAdminOrganisationResponse = z.infer<typeof ZGetAdminOrganisationResponseSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { currentMonthlyPeriod } from '@documenso/lib/server-only/rate-limit/current-monthly-period';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { adminProcedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZResetOrganisationMonthlyStatRequestSchema,
|
||||||
|
ZResetOrganisationMonthlyStatResponseSchema,
|
||||||
|
} from './reset-organisation-monthly-stat.types';
|
||||||
|
|
||||||
|
export const resetOrganisationMonthlyStatRoute = adminProcedure
|
||||||
|
.input(ZResetOrganisationMonthlyStatRequestSchema)
|
||||||
|
.output(ZResetOrganisationMonthlyStatResponseSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { organisationId, counter } = input;
|
||||||
|
|
||||||
|
const period = currentMonthlyPeriod();
|
||||||
|
|
||||||
|
ctx.logger.info({ organisationId, counter, period });
|
||||||
|
|
||||||
|
const data: Prisma.OrganisationMonthlyStatUpdateInput = match(counter)
|
||||||
|
.with('document', () => ({ documentCount: 0 }))
|
||||||
|
.with('email', () => ({ emailCount: 0 }))
|
||||||
|
.with('api', () => ({ apiCount: 0 }))
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
await prisma.organisationMonthlyStat.update({
|
||||||
|
where: { organisationId_period: { organisationId, period } },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZResetOrganisationMonthlyStatRequestSchema = z.object({
|
||||||
|
organisationId: z.string(),
|
||||||
|
counter: z.enum(['document', 'email', 'api']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZResetOrganisationMonthlyStatResponseSchema = z.void();
|
||||||
|
|
||||||
|
export type TResetOrganisationMonthlyStatRequest = z.infer<typeof ZResetOrganisationMonthlyStatRequestSchema>;
|
||||||
@@ -27,6 +27,7 @@ import { getUserRoute } from './get-user';
|
|||||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||||
import { reregisterEmailDomainRoute } from './reregister-email-domain';
|
import { reregisterEmailDomainRoute } from './reregister-email-domain';
|
||||||
import { resealDocumentRoute } from './reseal-document';
|
import { resealDocumentRoute } from './reseal-document';
|
||||||
|
import { resetOrganisationMonthlyStatRoute } from './reset-organisation-monthly-stat';
|
||||||
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
||||||
import { resyncLicenseRoute } from './resync-license';
|
import { resyncLicenseRoute } from './resync-license';
|
||||||
import { swapOrganisationSubscriptionRoute } from './swap-organisation-subscription';
|
import { swapOrganisationSubscriptionRoute } from './swap-organisation-subscription';
|
||||||
@@ -45,6 +46,7 @@ export const adminRouter = router({
|
|||||||
update: updateAdminOrganisationRoute,
|
update: updateAdminOrganisationRoute,
|
||||||
delete: deleteOrganisationRoute,
|
delete: deleteOrganisationRoute,
|
||||||
swapSubscription: swapOrganisationSubscriptionRoute,
|
swapSubscription: swapOrganisationSubscriptionRoute,
|
||||||
|
resetMonthlyStat: resetOrganisationMonthlyStatRoute,
|
||||||
},
|
},
|
||||||
organisationMember: {
|
organisationMember: {
|
||||||
promoteToOwner: promoteMemberToOwnerRoute,
|
promoteToOwner: promoteMemberToOwnerRoute,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||||
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
|
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||||
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { SubscriptionStatus } from '@prisma/client';
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
|
||||||
@@ -85,6 +86,8 @@ export const swapOrganisationSubscriptionRoute = adminProcedure
|
|||||||
|
|
||||||
const customerId = sourceOrg.customerId ?? sourceOrg.subscription.customerId;
|
const customerId = sourceOrg.customerId ?? sourceOrg.subscription.customerId;
|
||||||
|
|
||||||
|
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// Delete stale INACTIVE subscription on target if present.
|
// Delete stale INACTIVE subscription on target if present.
|
||||||
if (targetOrg.subscription) {
|
if (targetOrg.subscription) {
|
||||||
@@ -120,6 +123,7 @@ export const swapOrganisationSubscriptionRoute = adminProcedure
|
|||||||
teamCount: sourceOrg.organisationClaim.teamCount,
|
teamCount: sourceOrg.organisationClaim.teamCount,
|
||||||
memberCount: sourceOrg.organisationClaim.memberCount,
|
memberCount: sourceOrg.organisationClaim.memberCount,
|
||||||
envelopeItemCount: sourceOrg.organisationClaim.envelopeItemCount,
|
envelopeItemCount: sourceOrg.organisationClaim.envelopeItemCount,
|
||||||
|
recipientCount: sourceOrg.organisationClaim.recipientCount,
|
||||||
flags: sourceOrg.organisationClaim.flags,
|
flags: sourceOrg.organisationClaim.flags,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -131,7 +135,7 @@ export const swapOrganisationSubscriptionRoute = adminProcedure
|
|||||||
where: { id: sourceOrg.organisationClaim.id },
|
where: { id: sourceOrg.organisationClaim.id },
|
||||||
data: {
|
data: {
|
||||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||||
...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]),
|
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ export const ZUpdateAdminOrganisationRequestSchema = z.object({
|
|||||||
teamCount: true,
|
teamCount: true,
|
||||||
memberCount: true,
|
memberCount: true,
|
||||||
envelopeItemCount: true,
|
envelopeItemCount: true,
|
||||||
|
recipientCount: true,
|
||||||
flags: true,
|
flags: true,
|
||||||
|
documentRateLimits: true,
|
||||||
|
documentQuota: true,
|
||||||
|
emailRateLimits: true,
|
||||||
|
emailQuota: true,
|
||||||
|
apiRateLimits: true,
|
||||||
|
apiQuota: true,
|
||||||
}).optional(),
|
}).optional(),
|
||||||
customerId: z.string().optional(),
|
customerId: z.string().optional(),
|
||||||
originalSubscriptionClaimId: z.string().optional(),
|
originalSubscriptionClaimId: z.string().optional(),
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer
|
|||||||
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||||
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
|
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||||
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { generateStripeOrganisationCreateMetadata } from '@documenso/lib/utils/billing';
|
import { generateStripeOrganisationCreateMetadata } from '@documenso/lib/utils/billing';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationType } from '@prisma/client';
|
import { OrganisationType } from '@prisma/client';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import { ZCreateOrganisationRequestSchema, ZCreateOrganisationResponseSchema } from './create-organisation.types';
|
import { ZCreateOrganisationRequestSchema, ZCreateOrganisationResponseSchema } from './create-organisation.types';
|
||||||
|
|
||||||
@@ -66,11 +66,13 @@ export const createOrganisationRoute = authenticatedProcedure
|
|||||||
// Free organisations should be Personal by default.
|
// Free organisations should be Personal by default.
|
||||||
const organisationType = IS_BILLING_ENABLED() ? OrganisationType.PERSONAL : OrganisationType.ORGANISATION;
|
const organisationType = IS_BILLING_ENABLED() ? OrganisationType.PERSONAL : OrganisationType.ORGANISATION;
|
||||||
|
|
||||||
|
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||||
|
|
||||||
await createOrganisation({
|
await createOrganisation({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
name,
|
name,
|
||||||
type: organisationType,
|
type: organisationType,
|
||||||
claim: internalClaims[INTERNAL_CLAIM_ID.FREE],
|
claim: freeSubscriptionClaim,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user