diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index d4e4a5168..493d07138 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -1,5 +1,6 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Document } from '@documenso/prisma/types/document-legacy-schema'; @@ -30,6 +31,7 @@ import { useForm, useWatch } from 'react-hook-form'; import * as z from 'zod'; import { useCurrentTeam } from '~/providers/team'; +import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; import { StackAvatar } from '../general/stack-avatar'; @@ -99,9 +101,12 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia setIsOpen(false); } catch (err) { + const error = AppError.parseError(err); + const errorMessage = getDistributeErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`This document could not be re-sent at this time. Please try again.`), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index daac27df1..750ab38f2 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -1,6 +1,7 @@ import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError } from '@documenso/lib/errors/app-error'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { zEmail } from '@documenso/lib/utils/zod'; @@ -37,6 +38,7 @@ import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import * as z from 'zod'; +import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; export type EnvelopeDistributeDialogProps = { onDistribute?: () => Promise; @@ -66,7 +68,7 @@ export const EnvelopeDistributeDialog = ({ const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor(); const { toast } = useToast(); - const { t } = useLingui(); + const { t, i18n } = useLingui(); const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); @@ -174,9 +176,13 @@ export const EnvelopeDistributeDialog = ({ setIsOpen(false); } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = getDistributeErrorMessage(error.code); + toast({ - title: t`Something went wrong`, - description: t`This envelope could not be distributed at this time. Please try again.`, + title: i18n._(errorMessage.title), + description: i18n._(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx index c645df0dd..161fd94d0 100644 --- a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx @@ -1,4 +1,5 @@ import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; @@ -25,7 +26,7 @@ import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; - +import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; import { StackAvatar } from '../general/stack-avatar'; export type EnvelopeRedistributeDialogProps = { @@ -47,7 +48,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist const recipients = envelope.recipients; const { toast } = useToast(); - const { t } = useLingui(); + const { t, i18n } = useLingui(); const [isOpen, setIsOpen] = useState(false); @@ -77,9 +78,12 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist setIsOpen(false); } catch (err) { + const error = AppError.parseError(err); + const errorMessage = getDistributeErrorMessage(error.code); + toast({ - title: t`Something went wrong`, - description: t`This envelope could not be resent at this time. Please try again.`, + title: i18n._(errorMessage.title), + description: i18n._(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index 5baa48584..48507fb7d 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -4,7 +4,7 @@ import { TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, } from '@documenso/lib/constants/template'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { AppError } from '@documenso/lib/errors/app-error'; import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; @@ -35,8 +35,8 @@ import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; -import { match } from 'ts-pattern'; import * as z from 'zod'; +import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages'; const ZAddRecipientsForNewDocumentSchema = z.object({ distributeDocument: z.boolean(), @@ -180,22 +180,11 @@ export function TemplateUseDialog({ await navigate(documentPath); } catch (err) { const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with('DOCUMENT_SEND_FAILED', () => msg`The document was created but could not be sent to recipients.`) - .with( - AppErrorCode.INVALID_BODY, - AppErrorCode.INVALID_REQUEST, - () => - msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`, - ) - .with(AppErrorCode.NOT_FOUND, () => msg`The template or one of its recipients could not be found.`) - .with(AppErrorCode.LIMIT_EXCEEDED, () => msg`You have reached your document limit for this plan.`) - .otherwise(() => msg`An error occurred while creating document from template.`); + const errorMessage = getTemplateUseErrorMessage(error.code); toast({ - title: _(msg`Error`), - description: _(errorMessage), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', }); } diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index e0e37d35c..20a0a23fa 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -3,6 +3,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { AppError } from '@documenso/lib/errors/app-error'; import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema'; import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -42,6 +43,7 @@ import { useSearchParams } from 'react-router'; import { BrandingLogo } from '~/components/general/branding-logo'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { injectCss } from '~/utils/css-vars'; +import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages'; import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form'; import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover'; @@ -259,9 +261,12 @@ export const EmbedDirectTemplateClientPage = ({ ); } + const error = AppError.parseError(err); + const errorMessage = getDirectTemplateErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`We were unable to submit this document at this time. Please try again later.`), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 1cdb03276..24753f7e0 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -1,4 +1,5 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { TTemplate } from '@documenso/lib/types/template'; import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -17,6 +18,7 @@ import { useNavigate, useSearchParams } from 'react-router'; import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; +import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages'; import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form'; import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form'; @@ -120,9 +122,12 @@ export const DirectTemplatePageView = ({ await navigate(`/sign/${token}/complete`); } } catch (err) { + const error = AppError.parseError(err); + const errorMessage = getDirectTemplateErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`We were unable to submit this document at this time. Please try again later.`), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', }); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 782832044..051ee562c 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -1,6 +1,7 @@ import { DocumentSignatureType } from '@documenso/lib/constants/document'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { TDocument } from '@documenso/lib/types/document'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -25,9 +26,9 @@ import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client'; import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { z } from 'zod'; - import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { useCurrentTeam } from '~/providers/team'; +import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; export type DocumentEditFormProps = { className?: string; @@ -387,9 +388,12 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath } catch (err) { console.error(err); + const error = AppError.parseError(err); + const errorMessage = getDistributeErrorMessage(error.code); + toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while sending the document.`), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx index 225e13cd0..c53cd93c0 100644 --- a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx +++ b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -3,7 +3,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { AppError } from '@documenso/lib/errors/app-error'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types'; @@ -20,9 +20,9 @@ import { EnvelopeType } from '@prisma/client'; import { useMemo, useState } from 'react'; import type { FileRejection } from 'react-dropzone'; import { useNavigate, useParams } from 'react-router'; -import { match } from 'ts-pattern'; import { useCurrentTeam } from '~/providers/team'; +import { getUploadErrorMessage } from '~/utils/toast-error-messages'; export type DocumentUploadButtonLegacyProps = { className?: string; @@ -130,30 +130,11 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu console.error(err); - const errorMessage = match(error.code) - .with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`) - .with( - AppErrorCode.LIMIT_EXCEEDED, - () => msg`You have reached your document limit for this month. Please upgrade your plan.`, - ) - .with( - 'ENVELOPE_ITEM_LIMIT_EXCEEDED', - () => msg`You have reached the limit of the number of files per envelope.`, - ) - .with('UNSUPPORTED_FILE_TYPE', () => msg`This file type isn't supported. Please upload a PDF or Word document.`) - .with( - 'CONVERSION_SERVICE_UNAVAILABLE', - () => msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`, - ) - .with( - 'CONVERSION_FAILED', - () => msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`, - ) - .otherwise(() => msg`An error occurred while uploading your document.`); + const errorMessage = getUploadErrorMessage(error.code); toast({ - title: _(msg`Error`), - description: _(errorMessage), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index dc7a01d4d..3c2430a37 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -16,6 +16,7 @@ import { } from '@documenso/ui/components/recipient/recipient-autocomplete-input'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; @@ -563,6 +564,9 @@ export const EnvelopeEditorRecipientForm = () => { } }, [formValues]); + const recipientCountLimit = organisation.organisationClaim.recipientCount; + const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit; + return ( @@ -627,6 +631,17 @@ export const EnvelopeEditorRecipientForm = () => { + {isOverRecipientLimit && ( + + + + This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need + more. + + + + )} +
t`You cannot upload encrypted PDFs.`) - .with( - AppErrorCode.LIMIT_EXCEEDED, - () => t`You have reached your document limit for this month. Please upgrade your plan.`, - ) - .with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`) - .with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`) - .with( - 'CONVERSION_SERVICE_UNAVAILABLE', - () => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`, - ) - .with( - 'CONVERSION_FAILED', - () => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`, - ) - .otherwise(() => t`An error occurred during upload.`); + const errorMessage = getUploadErrorMessage(error.code); toast({ - title: t`Error`, - description: errorMessage, + title: i18n._(errorMessage.title), + description: i18n._(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx index 3135fb3d3..25611fd40 100644 --- a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx @@ -2,7 +2,7 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { AppError } from '@documenso/lib/errors/app-error'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types'; @@ -17,9 +17,9 @@ import { EnvelopeType } from '@prisma/client'; import { useMemo, useState } from 'react'; import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone'; import { useNavigate } from 'react-router'; -import { match } from 'ts-pattern'; import { useCurrentTeam } from '~/providers/team'; +import { getUploadErrorMessage } from '~/utils/toast-error-messages'; export type EnvelopeUploadButtonProps = { className?: string; @@ -112,27 +112,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo console.error(err); - const errorMessage = match(error.code) - .with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`) - .with( - AppErrorCode.LIMIT_EXCEEDED, - () => t`You have reached your document limit for this month. Please upgrade your plan.`, - ) - .with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`) - .with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`) - .with( - 'CONVERSION_SERVICE_UNAVAILABLE', - () => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`, - ) - .with( - 'CONVERSION_FAILED', - () => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`, - ) - .otherwise(() => t`An error occurred while uploading your document.`); + const errorMessage = getUploadErrorMessage(error.code); toast({ - title: t`Error`, - description: errorMessage, + title: i18n._(errorMessage.title), + description: i18n._(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx b/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx new file mode 100644 index 000000000..c25f23bb3 --- /dev/null +++ b/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx @@ -0,0 +1,124 @@ +import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { SUPPORT_EMAIL } from '@documenso/lib/constants/app'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; +import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { Trans } from '@lingui/react/macro'; +import { AlertTriangle } from 'lucide-react'; +import { useState } from 'react'; + +export const OrganisationQuotaBanner = () => { + const [isOpen, setIsOpen] = useState(false); + + const organisation = useOptionalCurrentOrganisation(); + + const { data: quotaFlags } = trpc.organisation.getQuotaFlags.useQuery( + { organisationId: organisation?.id ?? '' }, + { + enabled: Boolean(organisation), + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + ...SKIP_QUERY_BATCH_META, + refetchInterval: 1000 * 60, + refetchIntervalInBackground: false, + }, + ); + + const isAnyQuotaExceeded = Boolean( + quotaFlags?.isDocumentQuotaExceeded || quotaFlags?.isEmailQuotaExceeded || quotaFlags?.isApiQuotaExceeded, + ); + + // Every member of the organisation sees the banner when a quota is exhausted. + // Note: Skipping free plan banner for now because their quota can incorrectly show as exceeded. + if ( + !organisation || + !quotaFlags || + !isAnyQuotaExceeded || + organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE + ) { + return null; + } + + return ( + <> +
+
+
+ + + Your organisation has exceeded a fair use limit +
+ + +
+
+ + + + + + Fair use limit exceeded + + + + + Your organisation has exceeded a fair use limit. Please contact{' '} + + support + {' '} + to review your plan's limits. + + + + + + +
    + {quotaFlags.isDocumentQuotaExceeded && ( +
  • + Document creation has been temporarily paused +
  • + )} + {quotaFlags.isEmailQuotaExceeded && ( +
  • + Email sending has been temporarily paused +
  • + )} + {quotaFlags.isApiQuotaExceeded && ( +
  • + API requests have been temporarily paused +
  • + )} +
+
+
+ + + + + + +
+
+ + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/_layout.tsx b/apps/remix/app/routes/_authenticated+/_layout.tsx index 3e53010da..41fb19956 100644 --- a/apps/remix/app/routes/_authenticated+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/_layout.tsx @@ -13,6 +13,7 @@ import { AppBanner } from '~/components/general/app-banner'; import { Header } from '~/components/general/app-header'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { OrganisationBillingBanner } from '~/components/general/organisations/organisation-billing-banner'; +import { OrganisationQuotaBanner } from '~/components/general/organisations/organisation-quota-banner'; import { VerifyEmailBanner } from '~/components/general/verify-email-banner'; import { TeamProvider } from '~/providers/team'; @@ -109,6 +110,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP + + {!user.emailVerified && } {banner && !hideHeader && } diff --git a/apps/remix/app/utils/toast-error-messages.ts b/apps/remix/app/utils/toast-error-messages.ts new file mode 100644 index 000000000..0f2ce3031 --- /dev/null +++ b/apps/remix/app/utils/toast-error-messages.ts @@ -0,0 +1,97 @@ +import { AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { match } from 'ts-pattern'; + +export type ToastMessageDescriptor = { + title: MessageDescriptor; + description: MessageDescriptor; +}; + +export const RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE = { + title: msg`Too many recipients`, + description: msg`This document has too many recipients. Please remove some recipients or contact support if you need more.`, +}; + +export const FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE = { + title: msg`Fair use limit exceeded`, + description: msg`Your organisation has reached its plan's fair use limit. Please contact your organisation administrator or support to continue.`, +}; + +export const getDistributeErrorMessage = (code: string): ToastMessageDescriptor => { + return match(code) + .with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE) + .with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE) + .otherwise(() => ({ + title: msg`Something went wrong`, + description: msg`An error occurred while distributing the document.`, + })); +}; + +export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescriptor => { + return match(code) + .with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE) + .with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE) + .otherwise(() => ({ + title: msg`Something went wrong`, + description: msg`We were unable to submit this document at this time. Please try again later.`, + })); +}; + +export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => { + return match(code) + .with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE) + .with('INVALID_DOCUMENT_FILE', () => ({ + title: msg`Error`, + description: msg`You cannot upload encrypted PDFs.`, + })) + .with(AppErrorCode.LIMIT_EXCEEDED, () => ({ + title: msg`Error`, + description: msg`You have reached your document limit for this month. Please upgrade your plan.`, + })) + .with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => ({ + title: msg`Error`, + description: msg`You have reached the limit of the number of files per envelope.`, + })) + .with('UNSUPPORTED_FILE_TYPE', () => ({ + title: msg`Error`, + description: msg`This file type isn't supported. Please upload a PDF or Word document.`, + })) + .with('CONVERSION_SERVICE_UNAVAILABLE', () => ({ + title: msg`Error`, + description: msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`, + })) + .with('CONVERSION_FAILED', () => ({ + title: msg`Error`, + description: msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`, + })) + .otherwise(() => ({ + title: msg`Error`, + description: msg`An error occurred while uploading your document.`, + })); +}; + +export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor => { + return match(code) + .with('DOCUMENT_SEND_FAILED', () => ({ + title: msg`Error`, + description: msg`The document was created but could not be sent to recipients.`, + })) + .with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({ + title: msg`Error`, + description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`, + })) + .with(AppErrorCode.NOT_FOUND, () => ({ + title: msg`Error`, + description: msg`The template or one of its recipients could not be found.`, + })) + .with(AppErrorCode.LIMIT_EXCEEDED, () => ({ + title: msg`Error`, + description: msg`You have reached your document limit for this plan. Please upgrade your plan.`, + })) + .with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE) + .otherwise(() => ({ + title: msg`Error`, + description: msg`An error occurred while creating document from template.`, + })); +}; diff --git a/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts index 8648e5360..ea4ebe36b 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts @@ -2,7 +2,6 @@ import OrganisationLimitExceededEmailTemplate from '@documenso/email/templates/o 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'; @@ -94,4 +93,22 @@ export const run = async ({ }); }); } + + // Todo: Logging + // Todo: Decide if we want to send an email or alert via another software. + // const i18n = await getI18nInstance('en'); + + // // Email our support team. + // await mailer.sendMail({ + // to: SUPPORT_EMAIL, + // from: senderEmail, + // subject: i18n._(msg`An organisation has exceeded their fair use limits`), + // text: ` + // Organisation: ${organisation.name} + // Organisation ID: ${organisation.id} + // Counter: ${payload.counter} + // Kind: ${payload.kind} + // Period: ${payload.period} + // `, + // }); }; diff --git a/packages/lib/server-only/rate-limit/compute-quota-flags.ts b/packages/lib/server-only/rate-limit/compute-quota-flags.ts new file mode 100644 index 000000000..c53256606 --- /dev/null +++ b/packages/lib/server-only/rate-limit/compute-quota-flags.ts @@ -0,0 +1,47 @@ +export type QuotaFlags = { + isDocumentQuotaExceeded: boolean; + isEmailQuotaExceeded: boolean; + isApiQuotaExceeded: boolean; +}; + +type ComputeQuotaFlagsOptions = { + quotas: { + documentQuota: number | null; + emailQuota: number | null; + apiQuota: number | null; + }; + usage?: { + documentCount?: number; + emailCount?: number; + apiCount?: number; + }; +}; + +/** + * A quota of `null` means unlimited (never exceeded). A quota of `0` means + * blocked (always exceeded). Otherwise usage `>=` quota is exceeded. + * + * Note: this `>=` is intentionally the "reached" signal for the banner and is + * distinct from enforcement in `check-monthly-quota.ts`, which blocks the + * action that crosses the boundary using a strict `>` on the post-increment + * count. Do not "align" them — they answer different questions. + */ +const isQuotaExceeded = (quota: number | null, usage: number): boolean => { + if (quota === null) { + return false; + } + + if (quota === 0) { + return true; + } + + return usage >= quota; +}; + +export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => { + return { + isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0), + isEmailQuotaExceeded: isQuotaExceeded(quotas.emailQuota, usage?.emailCount ?? 0), + isApiQuotaExceeded: isQuotaExceeded(quotas.apiQuota, usage?.apiCount ?? 0), + }; +}; diff --git a/packages/lib/server-only/rate-limit/types.ts b/packages/lib/server-only/rate-limit/types.ts index 2bcada3dc..61d4e8d76 100644 --- a/packages/lib/server-only/rate-limit/types.ts +++ b/packages/lib/server-only/rate-limit/types.ts @@ -1,16 +1,5 @@ -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; diff --git a/packages/lib/types/organisation.ts b/packages/lib/types/organisation.ts index 5bd6351f7..c2bcaabf6 100644 --- a/packages/lib/types/organisation.ts +++ b/packages/lib/types/organisation.ts @@ -20,6 +20,7 @@ export const ZOrganisationSchema = OrganisationSchema.pick({ originalSubscriptionClaimId: true, teamCount: true, memberCount: true, + recipientCount: true, flags: true, }), }); diff --git a/packages/trpc/server/organisation-router/get-organisation-quota-flags.ts b/packages/trpc/server/organisation-router/get-organisation-quota-flags.ts new file mode 100644 index 000000000..2b0c408ea --- /dev/null +++ b/packages/trpc/server/organisation-router/get-organisation-quota-flags.ts @@ -0,0 +1,55 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { computeQuotaFlags } from '@documenso/lib/server-only/rate-limit/compute-quota-flags'; +import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; +import { authenticatedProcedure } from '../trpc'; +import { + ZGetOrganisationQuotaFlagsRequestSchema, + ZGetOrganisationQuotaFlagsResponseSchema, +} from './get-organisation-quota-flags.types'; + +export const getOrganisationQuotaFlagsRoute = authenticatedProcedure + .input(ZGetOrganisationQuotaFlagsRequestSchema) + .output(ZGetOrganisationQuotaFlagsResponseSchema) + .query(async ({ input, ctx }) => { + const { organisationId } = input; + const userId = ctx.user.id; + + ctx.logger.info({ + input: { + organisationId, + }, + }); + + // Any member of the organisation may view quota usage flags. + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ + organisationId, + userId, + }), + include: { + organisationClaim: true, + monthlyStats: { + where: { + period: currentMonthlyPeriod(), + }, + }, + }, + }); + + if (!organisation) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Organisation not found', + }); + } + + return computeQuotaFlags({ + quotas: { + documentQuota: organisation.organisationClaim.documentQuota, + emailQuota: organisation.organisationClaim.emailQuota, + apiQuota: organisation.organisationClaim.apiQuota, + }, + usage: organisation.monthlyStats[0] ?? undefined, + }); + }); diff --git a/packages/trpc/server/organisation-router/get-organisation-quota-flags.types.ts b/packages/trpc/server/organisation-router/get-organisation-quota-flags.types.ts new file mode 100644 index 000000000..e7713aeec --- /dev/null +++ b/packages/trpc/server/organisation-router/get-organisation-quota-flags.types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const ZGetOrganisationQuotaFlagsRequestSchema = z.object({ + organisationId: z.string().describe('The ID of the organisation.'), +}); + +/** + * Booleans only. Raw usage counts and quota caps are intentionally never + * surfaced to the client. + */ +export const ZGetOrganisationQuotaFlagsResponseSchema = z.object({ + isDocumentQuotaExceeded: z.boolean(), + isEmailQuotaExceeded: z.boolean(), + isApiQuotaExceeded: z.boolean(), +}); + +export type TGetOrganisationQuotaFlagsResponse = z.infer; diff --git a/packages/trpc/server/organisation-router/router.ts b/packages/trpc/server/organisation-router/router.ts index e3abe700a..3a66faab0 100644 --- a/packages/trpc/server/organisation-router/router.ts +++ b/packages/trpc/server/organisation-router/router.ts @@ -14,6 +14,7 @@ import { findOrganisationMemberInvitesRoute } from './find-organisation-member-i import { findOrganisationMembersRoute } from './find-organisation-members'; import { getOrganisationRoute } from './get-organisation'; import { getOrganisationMemberInvitesRoute } from './get-organisation-member-invites'; +import { getOrganisationQuotaFlagsRoute } from './get-organisation-quota-flags'; import { getOrganisationSessionRoute } from './get-organisation-session'; import { getOrganisationsRoute } from './get-organisations'; import { leaveOrganisationRoute } from './leave-organisation'; @@ -26,6 +27,7 @@ import { updateOrganisationSettingsRoute } from './update-organisation-settings' export const organisationRouter = router({ get: getOrganisationRoute, getMany: getOrganisationsRoute, + getQuotaFlags: getOrganisationQuotaFlagsRoute, create: createOrganisationRoute, update: updateOrganisationRoute, delete: deleteOrganisationRoute, diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index ef59230c1..9c77f16e2 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -29,6 +29,7 @@ import { prop, sortBy } from 'remeda'; import { DocumentReadOnlyFields, mapFieldsWithRecipients } from '../../components/document/document-read-only-fields'; import type { RecipientAutoCompleteOption } from '../../components/recipient/recipient-autocomplete-input'; import { RecipientAutoCompleteInput } from '../../components/recipient/recipient-autocomplete-input'; +import { Alert, AlertDescription } from '../alert'; import { Button } from '../button'; import { Checkbox } from '../checkbox'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; @@ -491,10 +492,24 @@ export const AddSignersFormPartial = ({ void handleAutoSave(); }, [form]); + const recipientCountLimit = organisation.organisationClaim.recipientCount; + const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit; + return ( <> + {isOverRecipientLimit && ( + + + + This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need + more. + + + + )} + {isDocumentPdfLoaded && (