diff --git a/.github/ISSUE_TEMPLATE/deploy-provider-request.yml b/.github/ISSUE_TEMPLATE/deploy-provider-request.yml new file mode 100644 index 000000000..c59154391 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/deploy-provider-request.yml @@ -0,0 +1,61 @@ +name: 'One-Click Deploy Provider Request' +description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README +title: 'One-Click Deploy Provider Request: [Provider Name]' +labels: ['deploy-provider-request'] +body: + - type: markdown + attributes: + value: | + Thanks for your interest in adding a one-click deploy option for Documenso! + + Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest. + + **How this works:** + + - 👍 this issue if you'd like to see Documenso deployable on this provider. + - If community interest is high enough, we'll consider adding it to the README. + - Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed. + - type: input + attributes: + label: Provider Name + placeholder: e.g. Railway + validations: + required: true + - type: input + attributes: + label: Provider Website + placeholder: e.g. https://railway.com + validations: + required: true + - type: input + attributes: + label: Deploy/Template URL + description: A link to an existing deployment template or deploy button URL, if one exists. + - type: dropdown + attributes: + label: Who creates and maintains the deployment template? + options: + - The provider + - Me / the community + - Nobody yet + validations: + required: true + - type: textarea + attributes: + label: Testing & Maintenance + description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)? + validations: + required: true + - type: textarea + attributes: + label: Why this provider? + description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc. + validations: + required: true + - type: checkboxes + attributes: + label: Please check the boxes that apply to this request. + options: + - label: I have searched existing issues to make sure this provider has not already been requested. + - label: I understand that inclusion depends on community interest and is not guaranteed. + - label: I understand that PRs adding deploy badges without a prior issue will be closed. diff --git a/README.md b/README.md index 642a285eb..a1ed84f30 100644 --- a/README.md +++ b/README.md @@ -186,21 +186,37 @@ For full instructions, requirements, and configuration details, see the [Self Ho ### One-Click Deploys -#### Railway +> [!NOTE] +> Want to see another provider listed here? Please [open a provider request](https://github.com/documenso/documenso/issues/new?template=deploy-provider-request.yml) instead of a PR so the community can signal interest. PRs adding deploy badges without a prior issue will be closed. -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) - -#### Render - -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso) - -#### Koyeb - -[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile) - -#### Elestio - -[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso) + + + + + + + + + + + +
+ + Deploy on Railway + + + + Deploy to Render + + + + Deploy to Koyeb + +
+ + Deploy on Elestio + +
## Security diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index ed495f83a..0cd5e3c4c 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; 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'; @@ -71,6 +72,7 @@ export const EnvelopeDistributeDialog = ({ const { toast } = useToast(); const { t, i18n } = useLingui(); const navigate = useNavigate(); + const analytics = useAnalytics(); const [isOpen, setIsOpen] = useState(false); const [isSyncing, setIsSyncing] = useState(false); @@ -200,6 +202,12 @@ export const EnvelopeDistributeDialog = ({ } catch (err) { const error = AppError.parseError(err); + analytics.captureException(err, { + source: 'editor', + location: 'distribute_document', + envelopeId: envelope.id, + }); + const errorMessage = getDistributeErrorMessage(error.code); toast({ diff --git a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx index 1a54c7893..b2d5da538 100644 --- a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; 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'; @@ -51,6 +52,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: const { toast } = useToast(); const { t, i18n } = useLingui(); + const analytics = useAnalytics(); const [isOpen, setIsOpen] = useState(false); @@ -95,6 +97,13 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: setIsOpen(false); } catch (err) { const error = AppError.parseError(err); + + analytics.captureException(err, { + source: 'editor', + location: 'redistribute_document', + envelopeId: envelope.id, + }); + const errorMessage = getDistributeErrorMessage(error.code); toast({ 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 20a0a23fa..3e2ca1d38 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 @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; @@ -77,6 +78,7 @@ export const EmbedDirectTemplateClientPage = ({ }: EmbedDirectTemplateClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const [searchParams] = useSearchParams(); @@ -264,6 +266,13 @@ export const EmbedDirectTemplateClientPage = ({ const error = AppError.parseError(err); const errorMessage = getDirectTemplateErrorMessage(error.code); + analytics.captureException(err, { + source: 'embed', + location: 'direct_template', + recipientId: recipient.id, + envelopeId, + }); + toast({ title: _(errorMessage.title), description: _(errorMessage.description), @@ -308,6 +317,14 @@ export const EmbedDirectTemplateClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + envelopeId, + }); + setHasFinishedInit(true); } diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index c8726ae22..739a5c8a6 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; @@ -75,6 +76,7 @@ export const EmbedSignDocumentV1ClientPage = ({ }: EmbedSignDocumentV1ClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext(); @@ -154,6 +156,14 @@ export const EmbedSignDocumentV1ClientPage = ({ setHasCompletedDocument(true); } catch (err) { + analytics.captureException(err, { + source: 'embed', + location: 'complete_document', + recipientId: recipient.id, + documentId, + envelopeId, + }); + if (window.parent) { window.parent.postMessage( { @@ -236,6 +246,15 @@ export const EmbedSignDocumentV1ClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + documentId, + envelopeId, + }); + setHasFinishedInit(true); } diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx index 969511713..55e7cb6d1 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; @@ -25,6 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({ allowWhitelabelling = false, }: EmbedSignDocumentV2ClientPageProps) => { const { _ } = useLingui(); + const analytics = useAnalytics(); const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } = useRequiredEnvelopeSigningContext(); @@ -170,6 +172,14 @@ export const EmbedSignDocumentV2ClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + envelopeId: envelope.id, + }); + setHasFinishedInit(true); } diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index 15902715d..8964033a6 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -57,6 +58,7 @@ export const MultiSignDocumentSigningView = ({ }: MultiSignDocumentSigningViewProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); @@ -101,6 +103,13 @@ export const MultiSignDocumentSigningView = ({ console.error(err); + analytics.captureException(err, { + source: 'embed', + location: 'sign_field', + recipientId, + documentId: document?.id, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while signing the document.`), @@ -120,6 +129,13 @@ export const MultiSignDocumentSigningView = ({ } console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'remove_field', + recipientId, + documentId: document?.id, + }); } }; @@ -140,6 +156,13 @@ export const MultiSignDocumentSigningView = ({ recipientId, }); } catch (err) { + analytics.captureException(err, { + source: 'embed', + location: 'complete_document', + recipientId, + documentId: document?.id, + }); + onDocumentError?.(); const error = AppError.parseError(err); 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 9f6ea47dc..58ea7d50f 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,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; 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'; @@ -41,6 +42,7 @@ export const DirectTemplatePageView = ({ const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const { email, fullName, setEmail } = useRequiredDocumentSigningContext(); const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext(); @@ -124,6 +126,13 @@ export const DirectTemplatePageView = ({ const error = AppError.parseError(err); const errorMessage = getDirectTemplateErrorMessage(error.code); + analytics.captureException(err, { + source: 'signing', + location: 'direct_template', + recipientId: directTemplateRecipient.id, + envelopeId: template.envelopeId, + }); + toast({ title: _(errorMessage.title), description: _(errorMessage.description), diff --git a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx index 7e9a7adb6..dcf2e2d81 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -39,6 +40,7 @@ export const DocumentSigningCheckboxField = ({ const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -126,6 +128,13 @@ export const DocumentSigningCheckboxField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -157,6 +166,13 @@ export const DocumentSigningCheckboxField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), @@ -216,6 +232,13 @@ export const DocumentSigningCheckboxField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while updating the signature.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index afe1b067a..b8caa4a83 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; @@ -88,6 +89,7 @@ export const DocumentSigningCompleteDialog = ({ position, disableNameInput = false, }: DocumentSigningCompleteDialogProps) => { + const analytics = useAnalytics(); const { t, i18n } = useLingui(); const { toast } = useToast(); @@ -181,6 +183,11 @@ export const DocumentSigningCompleteDialog = ({ return; } + analytics.captureException(error, { + source: 'signing', + location: 'complete_document', + }); + // This dialog owns the completion error toast for every signing surface // so the user gets a specific, actionable message. Callers should run // their own side effects (e.g. embeds posting document-error) and diff --git a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx index 1d05d3017..51135630c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; @@ -39,6 +40,7 @@ export const DocumentSigningDateField = ({ const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -85,6 +87,13 @@ export const DocumentSigningDateField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -113,6 +122,13 @@ export const DocumentSigningDateField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx index 2ac26f87c..549eb6128 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -35,6 +36,7 @@ export const DocumentSigningDropdownField = ({ const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -86,6 +88,13 @@ export const DocumentSigningDropdownField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -120,6 +129,13 @@ export const DocumentSigningDropdownField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx index 408914336..8e88d99be 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -33,6 +34,7 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }: const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { email: providedEmail } = useRequiredDocumentSigningContext(); @@ -78,6 +80,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }: console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -106,6 +115,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }: } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx index 6a9b74a3d..b79bf40fe 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -38,6 +39,7 @@ export const DocumentSigningInitialsField = ({ const { toast } = useToast(); const { _ } = useLingui(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { fullName } = useRequiredDocumentSigningContext(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -84,6 +86,13 @@ export const DocumentSigningInitialsField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -112,6 +121,13 @@ export const DocumentSigningInitialsField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx index 71ebe6995..0b4660e5d 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -39,6 +40,7 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }: const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext(); @@ -116,6 +118,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }: console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -144,6 +153,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }: } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx index 01f0e9714..0dd92c0ed 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx @@ -1,4 +1,5 @@ import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -47,6 +48,7 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField } const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -142,6 +144,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField } console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -193,6 +202,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField } } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index b4bee8d6e..9ed38fbf1 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -1,4 +1,3 @@ -import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; @@ -90,8 +89,6 @@ export const DocumentSigningPageViewV1 = ({ ? authUser.twoFactorEnabled && authUser.email === recipient.email : false; - const analytics = useAnalytics(); - const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id); const [isExpanded, setIsExpanded] = useState(false); @@ -125,12 +122,6 @@ export const DocumentSigningPageViewV1 = ({ await completeDocumentWithToken(payload); - analytics.capture('App: Recipient has completed signing', { - signerId: recipient.id, - documentId: document.id, - timestamp: new Date().toISOString(), - }); - if (documentMeta?.redirectUrl) { window.location.href = documentMeta.redirectUrl; } else { diff --git a/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx index e9d6af196..b155344db 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -31,6 +32,7 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -91,6 +93,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -120,6 +129,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }: } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the selection.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx index ba70ad048..f0d07eac8 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -41,6 +42,7 @@ export function DocumentSigningRejectDialog({ }: DocumentSigningRejectDialogProps) { const { t } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const [searchParams] = useSearchParams(); const [isOpen, setIsOpen] = useState(false); @@ -76,6 +78,12 @@ export function DocumentSigningRejectDialog({ window.location.href = `/sign/${token}/rejected`; } } catch (err) { + analytics.captureException(err, { + source: 'signing', + location: 'reject_document', + documentId, + }); + toast({ title: t`Error`, description: t`An error occurred while rejecting the document. Please try again.`, diff --git a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx index 3ff8b1d68..ecab0b1bc 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -47,6 +48,7 @@ export const DocumentSigningSignatureField = ({ const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient } = useDocumentSigningRecipientContext(); @@ -157,6 +159,13 @@ export const DocumentSigningSignatureField = ({ console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while signing the document.`), @@ -183,6 +192,13 @@ export const DocumentSigningSignatureField = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the signature.`), diff --git a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx index f3f12063a..b99b7337c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx @@ -1,4 +1,5 @@ import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; @@ -50,6 +51,7 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }: const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); + const analytics = useAnalytics(); const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); @@ -170,6 +172,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }: console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: isAssistantMode @@ -200,6 +209,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }: } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'remove_field', + fieldType: field.type, + recipientId: field.recipientId, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while removing the field.`), 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 c53cd93c0..5be052dfc 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 @@ -1,5 +1,4 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; -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'; @@ -38,7 +37,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu const team = useCurrentTeam(); const navigate = useNavigate(); - const analytics = useAnalytics(); const organisation = useCurrentOrganisation(); const userTimezone = @@ -103,12 +101,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu description: _(msg`Your document has been uploaded successfully.`), duration: 5000, }); - - analytics.capture('App: Document Uploaded', { - userId: user.id, - documentId: id, - timestamp: new Date().toISOString(), - }); } // Handle legacy template creation. diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index 698f83651..c9deb51ad 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; @@ -39,6 +40,7 @@ import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector' export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => { const { t, i18n } = useLingui(); + const analytics = useAnalytics(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); @@ -276,6 +278,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR unsafeRenderFieldOnLayer(field); } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'editor', + location: 'envelope_page_render', + envelopeId: envelope.id, + }); + setRenderError(true); } }; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index c6fb38938..73493c385 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -1,4 +1,5 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; @@ -45,6 +46,7 @@ export const EnvelopeEditorUploadPage = () => { const { t, i18n } = useLingui(); const { maximumEnvelopeItemCount, remaining } = useLimits(); const { toast } = useToast(); + const analytics = useAnalytics(); const { envelope, @@ -213,6 +215,12 @@ export const EnvelopeEditorUploadPage = () => { const { data } = await createPromise.catch((error) => { console.error(error); + analytics.captureException(error, { + source: isEmbedded ? 'embed' : 'editor', + location: 'create_envelope_items', + envelopeId: envelope.id, + }); + // Set error state on files in batch upload. setLocalFiles((prev) => prev.map((uploadingFile) => @@ -290,6 +298,12 @@ export const EnvelopeEditorUploadPage = () => { } catch (error) { console.error(error); + analytics.captureException(error, { + source: isEmbedded ? 'embed' : 'editor', + location: 'replace_pdf', + envelopeId: envelope.id, + }); + toast({ title: t`Replace failed`, description: t`Something went wrong while replacing the PDF`, diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index 7fd72c703..015012a73 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { type PageRenderData, @@ -18,6 +19,7 @@ type GenericLocalField = TEnvelope['fields'][number] & { export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => { const { i18n } = useLingui(); + const analytics = useAnalytics(); const { envelopeStatus, @@ -114,6 +116,13 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender unsafeRenderFieldOnLayer(field); } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'editor', + location: 'envelope_page_render', + envelopeId: currentEnvelopeItem?.envelopeId, + }); + setRenderError(true); } }; diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx index 8caab7ac0..1c5c54ca0 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { type PageRenderData, @@ -53,6 +54,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { toast } = useToast(); + const analytics = useAnalytics(); const { envelopeData, @@ -426,6 +428,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache); } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'signing', + location: 'page_render', + recipientId: recipient.id, + envelopeId: envelope.id, + }); + setRenderError(true); } }; @@ -507,6 +517,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD } catch (err) { console.error(err); + analytics.captureException(err, { + source: 'signing', + location: 'sign_field', + fieldType: payload.type, + recipientId: recipient.id, + envelopeId: envelope.id, + }); + toast({ title: t`Error`, description: t`An error occurred while signing the field.`, diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx index 29f1dbeee..c522419ca 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx @@ -118,12 +118,6 @@ export const EnvelopeSignerCompleteDialog = () => { title: t`Document already signed`, description: t`This document was already signed and no further action was taken.`, }); - } else { - analytics.capture('App: Recipient has completed signing', { - signerId: recipient.id, - documentId: envelope.id, - timestamp: new Date().toISOString(), - }); } if (onDocumentCompleted) { @@ -148,6 +142,13 @@ export const EnvelopeSignerCompleteDialog = () => { const error = AppError.parseError(err); if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) { + analytics.captureException(err, { + source: 'signing', + location: 'complete_document', + recipientId: recipient.id, + envelopeId: envelope.id, + }); + onDocumentError?.(); } @@ -221,6 +222,13 @@ export const EnvelopeSignerCompleteDialog = () => { } catch (err) { console.log('err', err); + analytics.captureException(err, { + source: 'signing', + location: 'complete_document_next_signer', + recipientId: recipient.id, + envelopeId: envelope.id, + }); + onDocumentError?.(); // Rethrow so DocumentSigningCompleteDialog can toast a specific diff --git a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx index 3b1a0b188..cf7bfb67c 100644 --- a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx @@ -93,14 +93,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD duration: 5000, }); - if (type === EnvelopeType.DOCUMENT) { - analytics.capture('App: Document Uploaded', { - userId: user.id, - documentId: id, - timestamp: new Date().toISOString(), - }); - } - const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url); const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : ''; @@ -109,6 +101,11 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD } catch (err) { const error = AppError.parseError(err); + analytics.captureException(err, { + source: 'editor', + location: 'upload_document', + }); + const errorMessage = getUploadErrorMessage(error.code); toast({ 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 25611fd40..c177eb8fb 100644 --- a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx @@ -1,4 +1,5 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +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 { TIME_ZONES } from '@documenso/lib/constants/time-zones'; @@ -34,6 +35,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo const { t, i18n } = useLingui(); const { toast } = useToast(); const { user } = useSession(); + const analytics = useAnalytics(); const team = useCurrentTeam(); @@ -112,6 +114,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo console.error(err); + analytics.captureException(err, { + source: 'editor', + location: 'upload_document', + }); + const errorMessage = getUploadErrorMessage(error.code); toast({ diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx index c795485f2..3fa39a232 100644 --- a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx +++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer'; import { cn } from '@documenso/ui/lib/utils'; @@ -68,6 +69,7 @@ export default function PDFViewer({ }: PDFViewerProps) { const { t } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const $el = useRef(null); @@ -150,6 +152,11 @@ export default function PDFViewer({ console.error(err); setLoadingState('error'); + analytics.captureException(err, { + source: 'pdf_viewer', + location: 'pdf_load', + }); + toast({ title: t`Error`, description: t`An error occurred while loading the document.`, @@ -366,6 +373,8 @@ const PdfViewerPage = ({ * Manages rendering a page from a pdf. */ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => { + const analytics = useAnalytics(); + const [imageLoadingState, setImageLoadingState] = useState('loading'); const [imageUrl, setImageUrl] = useState(''); @@ -457,6 +466,12 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: if (!isCancelled) { console.error(err); + + analytics.captureException(err, { + source: 'pdf_viewer', + location: 'pdf_page_render', + }); + setImageLoadingState('error'); } } finally { diff --git a/apps/remix/app/entry.client.tsx b/apps/remix/app/entry.client.tsx index f76430676..57daa65e3 100644 --- a/apps/remix/app/entry.client.tsx +++ b/apps/remix/app/entry.client.tsx @@ -18,6 +18,23 @@ import './utils/polyfills/promise-with-resolvers'; * the page early, leaving dead event handlers (broken dropdowns, native form * submits). */ +/** + * Signing and direct template URLs contain recipient tokens which must never + * be sent to PostHog. Recipient context is attached explicitly via + * `recipientId` where needed instead. + */ +const redactTokensFromUrl = (value: string) => { + return value.replace(/(\/(?:sign|d|direct)\/)([^/?#]+)/g, '$1:token'); +}; + +const URL_EVENT_PROPERTIES = [ + '$current_url', + '$pathname', + '$referrer', + '$initial_referrer', + '$prev_pageview_pathname', +] as const; + function initPosthog() { const postHogConfig = extractPostHogConfig(); @@ -25,7 +42,34 @@ function initPosthog() { void import('posthog-js').then(({ default: posthog }) => { posthog.init(postHogConfig.key, { api_host: postHogConfig.host, + // Only create person profiles for identified (authenticated) users, + // anonymous recipients on signing pages stay anonymous. + person_profiles: 'identified_only', + // Explicit events only, autocapture on signing pages blows up usage + // without providing actionable data. + autocapture: false, + capture_pageview: true, + capture_pageleave: false, capture_exceptions: true, + before_send: (event) => { + if (!event) { + return null; + } + + for (const property of URL_EVENT_PROPERTIES) { + const value = event.properties?.[property]; + + if (typeof value === 'string') { + event.properties[property] = redactTokensFromUrl(value); + } + } + + if (event.$set_once && typeof event.$set_once['$initial_current_url'] === 'string') { + event.$set_once['$initial_current_url'] = redactTokensFromUrl(event.$set_once['$initial_current_url']); + } + + return event; + }, }); }); } diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 75b37495c..baffaa6ec 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -1,4 +1,5 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { SessionProvider } from '@documenso/lib/client-only/providers/session'; import { getBasePath } from '@documenso/lib/constants/app'; import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; @@ -9,6 +10,7 @@ import { getOrganisationSession } from '@documenso/trpc/server/organisation-rout import { Toaster } from '@documenso/ui/primitives/toaster'; import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'; +import { useEffect } from 'react'; import { data, isRouteErrorResponse, @@ -200,11 +202,19 @@ export default function App() { } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + const analytics = useAnalytics(); + const errorCode = isRouteErrorResponse(error) ? error.status : 500; if (errorCode !== 404) { console.error('[RootErrorBoundary]', error); } + useEffect(() => { + if (errorCode !== 404) { + analytics.captureException(error, { source: 'app', location: 'root_boundary' }); + } + }, [error]); + return ; } diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._layout.tsx index 578658441..0931f0474 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._layout.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._layout.tsx @@ -1,4 +1,5 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; @@ -7,6 +8,7 @@ import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { ChevronLeftIcon } from 'lucide-react'; +import { useEffect } from 'react'; import type { ShouldRevalidateFunctionArgs } from 'react-router'; import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router'; @@ -66,8 +68,18 @@ export default function DocumentsLayout() { } export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) { + const analytics = useAnalytics(); + const errorCode = isRouteErrorResponse(error) ? error.status : 500; + useEffect(() => { + analytics.captureException(error, { + source: 'editor', + location: 'editor_layout_boundary', + envelopeId: params.id, + }); + }, [error]); + const errorCodeMap = { 404: { subHeading: msg`404 Document not found`, diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._layout.tsx index 3edd8fd34..b0faefe1b 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._layout.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._layout.tsx @@ -1,4 +1,5 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; @@ -8,6 +9,7 @@ import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { ChevronLeftIcon } from 'lucide-react'; +import { useEffect } from 'react'; import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; @@ -84,8 +86,18 @@ export default function TemplatesLayout() { } export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) { + const analytics = useAnalytics(); + const errorCode = isRouteErrorResponse(error) ? error.status : 500; + useEffect(() => { + analytics.captureException(error, { + source: 'editor', + location: 'editor_layout_boundary', + envelopeId: params.id, + }); + }, [error]); + const errorCodeMap = { 404: { subHeading: msg`404 Template not found`, diff --git a/apps/remix/app/routes/_recipient+/_layout.tsx b/apps/remix/app/routes/_recipient+/_layout.tsx index 940218adb..fde4a7998 100644 --- a/apps/remix/app/routes/_recipient+/_layout.tsx +++ b/apps/remix/app/routes/_recipient+/_layout.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -5,6 +6,7 @@ import { i18n } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { ChevronLeft } from 'lucide-react'; +import { useEffect } from 'react'; import { isRouteErrorResponse, Link, Outlet } from 'react-router'; import { Header as AuthenticatedHeader } from '~/components/general/app-header'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; @@ -48,8 +50,14 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) { } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + const analytics = useAnalytics(); + const errorCode = isRouteErrorResponse(error) ? error.status : 500; + useEffect(() => { + analytics.captureException(error, { source: 'signing', location: 'recipient_layout_boundary' }); + }, [error]); + return ( { + const isExpectedEmbedResponse = + isRouteErrorResponse(error) && + [ + 'embed-authentication-required', + 'embed-paywall', + 'embed-waiting-for-turn', + 'embed-recipient-expired', + 'embed-document-rejected', + 'embed-document-completed', + ].includes(error.data?.type); + + if (isExpectedEmbedResponse) { + return; + } + + analytics.captureException(error, { source: 'embed', location: 'embed_layout_boundary' }); + }, [error]); + if (isRouteErrorResponse(error)) { if (error.status === 401 && error.data.type === 'embed-authentication-required') { return ( diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx index b041e01ca..f8e6d0630 100644 --- a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx +++ b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx @@ -2,11 +2,13 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing'; import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data'; import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; +import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { prisma } from '@documenso/prisma'; import { data } from 'react-router'; @@ -93,6 +95,30 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) { const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId); + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { + id: template.teamId, + }, + select: { + organisationId: true, + }, + }); + + captureServerEvent({ + event: 'App: Embed Session Started', + userId: user?.id, + organisationId: team?.organisationId, + teamId: template.teamId, + properties: { + type: 'signing', + version: 'v0', + recipientId: recipient.id, + envelopeId: template.envelopeId, + }, + }); + }); + return { token, user, @@ -194,6 +220,30 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) { ); } + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { + id: envelope.teamId, + }, + select: { + organisationId: true, + }, + }); + + captureServerEvent({ + event: 'App: Embed Session Started', + userId: user?.id, + organisationId: team?.organisationId, + teamId: envelope.teamId, + properties: { + type: 'signing', + version: 'v0', + recipientId: recipient.id, + envelopeId: envelope.id, + }, + }); + }); + return { token, user, diff --git a/apps/remix/app/routes/embed+/_v0+/sign.$token.tsx b/apps/remix/app/routes/embed+/_v0+/sign.$token.tsx index 997bf4e4a..f36ced9af 100644 --- a/apps/remix/app/routes/embed+/_v0+/sign.$token.tsx +++ b/apps/remix/app/routes/embed+/_v0+/sign.$token.tsx @@ -2,6 +2,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing'; @@ -13,6 +14,7 @@ import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; +import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { isRecipientExpired } from '@documenso/lib/utils/recipients'; @@ -139,6 +141,30 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) { }) : []; + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { + id: document.teamId, + }, + select: { + organisationId: true, + }, + }); + + captureServerEvent({ + event: 'App: Embed Session Started', + userId: user?.id, + organisationId: team?.organisationId, + teamId: document.teamId, + properties: { + type: 'signing', + version: 'v0', + recipientId: recipient.id, + envelopeId: document.envelopeId, + }, + }); + }); + return { token, user, @@ -272,6 +298,30 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) { recipientAccessAuth: derivedRecipientAccessAuth, }).catch(() => null); + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { + id: envelope.teamId, + }, + select: { + organisationId: true, + }, + }); + + captureServerEvent({ + event: 'App: Embed Session Started', + userId: user?.id, + organisationId: team?.organisationId, + teamId: envelope.teamId, + properties: { + type: 'signing', + version: 'v0', + recipientId: recipient.id, + envelopeId: envelope.id, + }, + }); + }); + return { token, user, diff --git a/apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx b/apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx index d55fe5e5c..b048d5663 100644 --- a/apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx +++ b/apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx @@ -1,8 +1,11 @@ import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema'; +import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; import { dynamicActivate } from '@documenso/lib/utils/i18n'; +import { prisma } from '@documenso/prisma'; import { TrpcProvider } from '@documenso/trpc/react'; import { Spinner } from '@documenso/ui/primitives/spinner'; import { Trans } from '@lingui/react/macro'; @@ -35,6 +38,40 @@ export const loader = async ({ request }: Route.LoaderArgs) => { }); allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false; + + // Derive the kind of authoring session from the child route path, e.g. + // /embed/v1/authoring/document/create -> resource 'document', mode 'create'. + // Completed and error pages are not new sessions and are skipped. + const [resource, mode] = url.pathname.split('/').filter(Boolean).slice(3); + + const isAuthoringSession = + (resource === 'document' || resource === 'template') && (mode === 'create' || mode === 'edit'); + + if (isAuthoringSession) { + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { + id: result.teamId, + }, + select: { + organisationId: true, + }, + }); + + captureServerEvent({ + event: 'App: Embed Session Started', + userId: result.userId, + organisationId: team?.organisationId, + teamId: result.teamId, + properties: { + type: 'authoring', + version: 'v1', + resource, + mode, + }, + }); + }); + } } return { diff --git a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx index 4bafee963..1759a6121 100644 --- a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx +++ b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx @@ -1,8 +1,11 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; +import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; +import { prisma } from '@documenso/prisma'; import { Trans } from '@lingui/react/macro'; import { SigningStatus } from '@prisma/client'; import { useEffect, useLayoutEffect, useState } from 'react'; @@ -42,6 +45,15 @@ export async function loader({ request }: Route.LoaderArgs) { const firstDocument = envelopes[0]?.document; if (!firstDocument) { + captureServerEvent({ + event: 'App: Embed Session Started', + userId: user?.id, + properties: { + type: 'signing', + version: 'v1', + }, + }); + return superLoaderJson({ envelopes, user, @@ -55,6 +67,29 @@ export async function loader({ request }: Route.LoaderArgs) { const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel; const hidePoweredBy = organisationClaim.flags.hidePoweredBy; + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { + id: firstDocument.teamId, + }, + select: { + organisationId: true, + }, + }); + + captureServerEvent({ + event: 'App: Embed Session Started', + userId: user?.id, + organisationId: team?.organisationId, + teamId: firstDocument.teamId, + properties: { + type: 'signing', + version: 'v1', + envelopeId: firstDocument.envelopeId, + }, + }); + }); + return superLoaderJson({ envelopes, user, diff --git a/apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx b/apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx index eb8b81ef4..87ded302a 100644 --- a/apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx +++ b/apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx @@ -1,5 +1,6 @@ import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants'; import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; @@ -12,7 +13,7 @@ import type { OrganisationSession } from '@documenso/trpc/server/organisation-ro import { Spinner } from '@documenso/ui/primitives/spinner'; import { Trans } from '@lingui/react/macro'; import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client'; -import { useLayoutEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useState } from 'react'; import { isRouteErrorResponse, Outlet, useLoaderData } from 'react-router'; import { match } from 'ts-pattern'; @@ -166,8 +167,17 @@ export default function AuthoringLayout() { } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + const analytics = useAnalytics(); + const errorCode = isRouteErrorResponse(error) ? error.status : 500; + useEffect(() => { + analytics.captureException(error, { + source: 'embed', + location: 'embed_authoring_boundary', + }); + }, [error]); + return (
{match(errorCode) diff --git a/apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx index bd000b8a8..2213ae56b 100644 --- a/apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx +++ b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx @@ -1,5 +1,6 @@ import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; @@ -10,6 +11,7 @@ import { ZEmbedCreateEnvelopeAuthoringSchema, } from '@documenso/lib/types/envelope-editor'; import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta'; +import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document'; import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config'; import { prisma } from '@documenso/prisma'; @@ -76,6 +78,30 @@ export const loader = async ({ request }: Route.LoaderArgs) => { }, }); + fireAndForget(async () => { + const team = result.teamId + ? await prisma.team.findFirst({ + where: { + id: result.teamId, + }, + select: { + organisationId: true, + }, + }) + : null; + + captureServerEvent({ + event: 'App: Embed Session Started', + userId: result.userId, + organisationId: team?.organisationId, + teamId: result.teamId ?? undefined, + properties: { + type: 'authoring', + version: 'v2', + }, + }); + }); + return superLoaderJson({ token, tokenUserId: result.userId, diff --git a/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx index 155f5f96c..b35c08b0c 100644 --- a/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx +++ b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx @@ -1,5 +1,6 @@ import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id'; import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; @@ -94,6 +95,18 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => { brandingLogo = settings.brandingLogo; } + captureServerEvent({ + event: 'App: Embed Session Started', + userId: result.userId, + organisationId: envelope.team.organisationId, + teamId: result.teamId ?? undefined, + properties: { + type: 'authoring', + version: 'v2', + envelopeId: envelope.id, + }, + }); + return superLoaderJson({ token, envelope, diff --git a/packages/lib/client-only/hooks/use-analytics.ts b/packages/lib/client-only/hooks/use-analytics.ts index ce302f7df..7d8953393 100644 --- a/packages/lib/client-only/hooks/use-analytics.ts +++ b/packages/lib/client-only/hooks/use-analytics.ts @@ -31,18 +31,21 @@ export function useAnalytics() { }; /** - * Capture an analytic event. + * Capture an exception event. * * @param error The error to capture. - * @param properties Properties to attach to the event. + * @param properties Properties to attach to the event, such as `source`, `location`, + * `recipientId` or `envelopeId`. Never attach recipient tokens. */ - const captureException = (error: Error, properties?: Record) => { + const captureException = (error: unknown, properties?: Record) => { if (!isPostHogEnabled) { return; } + const errorToCapture = error instanceof Error ? error : new Error(String(error)); + void getPosthog().then(({ default: posthog }) => { - posthog.captureException(error, properties); + posthog.captureException(errorToCapture, properties); }); }; diff --git a/packages/lib/client-only/providers/envelope-editor-provider.tsx b/packages/lib/client-only/providers/envelope-editor-provider.tsx index 69853ce5f..ff58665cf 100644 --- a/packages/lib/client-only/providers/envelope-editor-provider.tsx +++ b/packages/lib/client-only/providers/envelope-editor-provider.tsx @@ -23,6 +23,7 @@ import { useSearchParams } from 'react-router'; import type { TDocumentEmailSettings } from '../../types/document-email'; import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams'; +import { useAnalytics } from '../hooks/use-analytics'; import type { TLocalField } from '../hooks/use-editor-fields'; import { useEditorFields } from '../hooks/use-editor-fields'; import { useEditorRecipients } from '../hooks/use-editor-recipients'; @@ -115,6 +116,7 @@ export const EnvelopeEditorProvider = ({ }: EnvelopeEditorProviderProps) => { const { t } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const [_searchParams, setSearchParams] = useSearchParams(); @@ -289,6 +291,12 @@ export const EnvelopeEditorProvider = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: isEmbedded ? 'embed' : 'editor', + location: 'autosave_recipients', + envelopeId: envelope.id, + }); + setAutosaveError(true); toast({ @@ -352,6 +360,12 @@ export const EnvelopeEditorProvider = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: isEmbedded ? 'embed' : 'editor', + location: 'autosave_fields', + envelopeId: envelope.id, + }); + setAutosaveError(true); toast({ @@ -407,6 +421,12 @@ export const EnvelopeEditorProvider = ({ } catch (err) { console.error(err); + analytics.captureException(err, { + source: isEmbedded ? 'embed' : 'editor', + location: 'autosave_meta', + envelopeId: envelope.id, + }); + setAutosaveError(true); toast({ diff --git a/packages/lib/server-only/analytics/capture-server-event.ts b/packages/lib/server-only/analytics/capture-server-event.ts new file mode 100644 index 000000000..ba04ba96a --- /dev/null +++ b/packages/lib/server-only/analytics/capture-server-event.ts @@ -0,0 +1,83 @@ +import crypto from 'node:crypto'; +import { PostHog } from 'posthog-node'; + +import { env } from '../../utils/env'; + +/** + * Server-side product analytics client. + * + * Uses the same PostHog project as the client-side integration + * (NEXT_PUBLIC_POSTHOG_KEY). Not to be confused with the self-hoster + * telemetry client which reports to a separate project. + * + * Events are sent directly to PostHog rather than through the `/ingest` + * reverse proxy used by the browser. + */ +const POSTHOG_HOST = 'https://eu.i.posthog.com'; + +let client: PostHog | null | undefined; + +const getAnalyticsClient = () => { + if (client !== undefined) { + return client; + } + + const apiKey = env('NEXT_PUBLIC_POSTHOG_KEY'); + + // Low volume events, flush immediately so nothing is lost on shutdown. + client = apiKey ? new PostHog(apiKey, { host: POSTHOG_HOST, flushAt: 1 }) : null; + + return client; +}; + +type CaptureServerEventOptions = { + event: string; + + /** + * The authenticated user this event belongs to, if any. + * + * Used as the distinct ID so server events line up with client-side + * identified users. Anonymous events (e.g. embed signer sessions) get a + * random distinct ID instead. + */ + userId?: number; + organisationId?: string; + teamId?: number; + properties?: Record; +}; + +/** + * Capture a product analytics event from the server. + * + * No-ops when NEXT_PUBLIC_POSTHOG_KEY is not configured. Events are captured + * as anonymous events (no person profile processing) to keep usage costs low, + * cross-referencing is done via the `userId`, `organisationId` and `teamId` + * event properties. + * + * Never pass recipient tokens or document contents as properties. + */ +export const captureServerEvent = ({ + event, + userId, + organisationId, + teamId, + properties = {}, +}: CaptureServerEventOptions) => { + const posthog = getAnalyticsClient(); + + if (!posthog) { + return; + } + + posthog.capture({ + distinctId: userId ? String(userId) : `anon_${crypto.randomUUID()}`, + event, + properties: { + ...properties, + userId, + organisationId, + teamId, + $process_person_profile: false, + }, + }); +}; diff --git a/packages/lib/translations/pl/web.po b/packages/lib/translations/pl/web.po index f9eeddd0d..207cae377 100644 --- a/packages/lib/translations/pl/web.po +++ b/packages/lib/translations/pl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: pl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-08-13 14:22\n" +"PO-Revision-Date: 2026-08-27 12:58\n" "Last-Translator: \n" "Language-Team: Polish\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" @@ -316,7 +316,7 @@ msgstr "Sprawdź i {recipientActionVerb} dokument „{1}” utworzony przez zesp #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "{0} has invited you to sign this document." -msgstr "" +msgstr "Organizacja {0} zaprosiła Cię do podpisania dokumentu." #. placeholder {0}: team.name #: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -442,7 +442,7 @@ msgstr "Sprawdź i {action} dokument „{documentName}” utworzony przez użytk #: apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx msgid "{MAX_BULK_DOWNLOAD_ENVELOPES, plural, one {You can download up to # document at a time. Deselect some documents to continue.} other {You can download up to # documents at a time. Deselect some documents to continue.}}" -msgstr "" +msgstr "{MAX_BULK_DOWNLOAD_ENVELOPES, plural, one {Możesz pobrać jednocześnie # dokument. Odznacz niektóre dokumenty.} few {Możesz pobrać jednocześnie # dokumenty. Odznacz niektóre dokumenty.} many {Możesz pobrać jednocześnie # dokumentów. Odznacz niektóre dokumenty.} other {Możesz pobrać jednocześnie # dokumentów. Odznacz niektóre dokumenty.}}" #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "{MAXIMUM_PASSKEYS, plural, one {You cannot have more than # passkey.} other {You cannot have more than # passkeys.}}" @@ -525,11 +525,11 @@ msgstr "Sprawdź i {action} dokument „{documentName}” utworzony przez zespó #: apps/remix/app/components/general/app-command-menu.tsx msgid "{totalVisibleCount, plural, one {# item} other {# items}}" -msgstr "" +msgstr "{totalVisibleCount, plural, one {# element} few {# elementy} many {# elementów} other {# elementów}}" #: apps/remix/app/components/general/app-command-menu.tsx msgid "{totalVisibleCount, plural, one {# result} other {# results}}" -msgstr "" +msgstr "{totalVisibleCount, plural, one {# wynik} few {# wyniki} many {# wyników} other {# wyników}}" #: packages/lib/utils/document-audit-logs.ts msgid "{user} added a field" @@ -1237,7 +1237,7 @@ msgstr "Konto zostało rozłączone" #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Accounts are automatically added to your organisation on sign-in" -msgstr "" +msgstr "Automatyczne dodawanie kont do organizacji podczas logowania" #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Acknowledgment" @@ -2455,7 +2455,7 @@ msgstr "Obramowanie" #: apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx msgid "Border radius" -msgstr "Zaokrąglenia obramowania" +msgstr "Zaokrąglenie obramowania" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Border Radius" @@ -2471,7 +2471,7 @@ msgstr "Dół" #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx msgid "Brand accent" -msgstr "" +msgstr "Kolor marki" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Brand Colours" @@ -2479,7 +2479,7 @@ msgstr "Kolory marki" #: apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx msgid "Brand colours, including background, foreground, primary, and border colours" -msgstr "Kolorów marki, w tym kolorów tła, przycisków i obramowań" +msgstr "Kolory marki, w tym kolory tła, przycisków i obramowań" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Brand Details" @@ -2491,7 +2491,7 @@ msgstr "Strona internetowa marki" #: apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx msgid "Brand website and brand details" -msgstr "Strony internetowej i szczegółów marki" +msgstr "Strona internetowa i szczegóły marki" #: apps/remix/app/components/general/admin-global-settings-section.tsx #: packages/lib/utils/settings-nav.ts @@ -2581,7 +2581,7 @@ msgstr "Akceptując prośbę, umożliwisz zespołowi <0>{teamName}:" #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "By deleting this document, the following will occur:" -msgstr "Usunięcie dokumentu spowoduje następujące skutki:" +msgstr "Po usunięciu dokumentu:" #: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx msgid "By enabling 2FA, you will be required to enter a code from your authenticator app every time you sign in using email password." @@ -3008,11 +3008,11 @@ msgstr "Komunikacja" #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx msgid "Company details" -msgstr "" +msgstr "Szczegóły firmy" #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx msgid "Company details and website in email footers" -msgstr "" +msgstr "Szczegóły firmy i strona internetowa w stopkach" #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx msgid "Compare all plans and features in detail" @@ -3203,7 +3203,7 @@ msgstr "Informacje kontaktowe" #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Contact Sales" -msgstr "" +msgstr "Skontaktuj się z działem sprzedaży" #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx msgid "Contact sales here" @@ -3215,7 +3215,7 @@ msgstr "Skontaktuj się z nami" #: apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx msgid "Contact your organisation owner to upgrade plans." -msgstr "" +msgstr "Skontaktuj się z właścicielem organizacji, aby zaktualizować plan." #: apps/remix/app/components/general/admin-site-banner-section.tsx msgid "Content" @@ -3271,7 +3271,7 @@ msgstr "Przejdź do logowania" #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Continue with SSO" -msgstr "" +msgstr "Logowanie SSO" #: apps/remix/app/components/forms/reminder-preferences-form.tsx msgid "Controls how long recipients have to complete signing before the document expires. After expiration, recipients can no longer sign the document." @@ -3718,11 +3718,11 @@ msgstr "Niestandardowy plik {0} MB" #: apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx msgid "Custom branding enabled setting" -msgstr "Niestandardowych ustawień marki" +msgstr "Niestandardowe ustawienia marki" #: apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx msgid "Custom CSS" -msgstr "Niestandardowego CSS" +msgstr "Niestandardowy CSS" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be shown after you save." @@ -4376,7 +4376,7 @@ msgstr "Wszystkie dokumenty" #: apps/remix/app/utils/toast-error-messages.ts msgid "Document already completed" -msgstr "" +msgstr "Dokument został już zakończony" #: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx msgid "Document already signed" @@ -4565,7 +4565,7 @@ msgstr "Przeniesiono dokument do zespołu" #: apps/remix/app/utils/toast-error-messages.ts msgid "Document no longer available" -msgstr "" +msgstr "Dokument nie jest już dostępny" #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Document no longer available to sign" @@ -4573,7 +4573,7 @@ msgstr "Dokument nie jest już dostępny do podpisania" #: apps/remix/app/utils/toast-error-messages.ts msgid "Document not ready" -msgstr "" +msgstr "Dokument nie jest gotowy" #: packages/lib/utils/document-audit-logs.ts msgctxt "Audit log format" @@ -5033,7 +5033,7 @@ msgstr "np. Resend (plan darmowy)" #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "Easy DNS setup with auto-generated DKIM and SPF records" -msgstr "" +msgstr "Łatwa konfiguracja DNS z automatycznie generowanymi rekordami DKIM i SPF" #: apps/remix/app/components/general/document/document-page-view-button.tsx #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -5709,7 +5709,7 @@ msgstr "Wystąpił błąd podczas przesyłania pliku" #: apps/remix/app/components/general/app-command-menu.tsx msgid "esc" -msgstr "escape" +msgstr "esc" #: apps/remix/app/components/general/webhook-logs-sheet.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx @@ -6202,7 +6202,7 @@ msgstr "Niemiecki" #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan." -msgstr "" +msgstr "Zapewnij swoim użytkownikom logowanie SSO. Funkcja dostępna w planie Enterprise." #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-command-menu.tsx @@ -6438,7 +6438,7 @@ msgstr "Ukryj" #: packages/ui/components/document/envelope-recipient-field-tooltip.tsx msgid "Hide field" -msgstr "" +msgstr "Ukryj pole" #: apps/remix/app/components/general/admin-license-card.tsx msgid "Hide license key" @@ -6852,7 +6852,7 @@ msgstr "To nie jest Twoja kolej na podpisanie dokumentu. Gdy nadejdzie Twoja kol #: apps/remix/app/utils/toast-error-messages.ts msgid "It's not your turn to sign yet" -msgstr "" +msgstr "To nie jest Twoja kolej na podpisanie dokumentu" #: packages/lib/constants/i18n.ts msgid "Italian" @@ -6974,7 +6974,7 @@ msgstr "Użyto" #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx msgid "Last Used" -msgstr "" +msgstr "Użyto" #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx @@ -7603,7 +7603,7 @@ msgstr "Ustawienia nazwy" #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "Named senders with defaults per team, template or document" -msgstr "" +msgstr "Zdefiniowani nadawcy z domyślnymi ustawieniami dla zespołu, szablonu lub dokumentu" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Navigate" @@ -7990,14 +7990,14 @@ msgstr "Na tej stronie możesz utworzyć nowe webhooki i zarządzać obecnymi." #: apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx #: apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx msgid "Once confirmed, the following will be reset:" -msgstr "Potwierdzenie akcji spowoduje zresetowanie:" +msgstr "Po potwierdzeniu zresetowane zostaną:" #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" -msgstr "Potwierdzenie akcji spowoduje następujące skutki:" +msgstr "Po potwierdzeniu:" #: packages/lib/constants/template.ts msgid "Once enabled, you can select any active recipient to be a direct link signing recipient, or create a new one. This recipient type cannot be edited or deleted." @@ -8021,7 +8021,7 @@ msgstr "Jeden z dokumentów ma niezgodną rolę w procesie podpisywania." #: apps/remix/app/utils/toast-error-messages.ts msgid "One or more of your required fields have not been saved. Please refresh the page, complete any empty required fields, and try again." -msgstr "" +msgstr "Co najmniej jedno z wymaganych pól nie zostało zapisane. Odśwież stronę, uzupełnij wymagane pola i spróbuj ponownie." #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Only admins can access and view the document" @@ -8756,7 +8756,7 @@ msgstr "Wybierz, w jaki sposób chcesz otrzymać swój kod weryfikacyjny." #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "Please sign: Example.pdf" -msgstr "" +msgstr "Podpisz dokument: umowa.pdf" #: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx msgid "Please try a different domain." @@ -8789,6 +8789,10 @@ msgstr "Prześlij dokument, aby kontynuować" msgid "Please upload a logo" msgstr "Prześlij logo" +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +msgid "Please upload a valid image file." +msgstr "Prześlij prawidłową grafikę." + #: packages/lib/constants/i18n.ts msgid "Polish" msgstr "Polski" @@ -8946,7 +8950,7 @@ msgstr "Szablony publiczne są powiązane z Twoim profilem publicznym. Wszelkie #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx msgid "Put your own brand on every document you send. Branding is available on the Teams plan and above." -msgstr "" +msgstr "Umieść swoje logo na każdym wysyłanym dokumencie. Branding jest dostępny w planie Teams i wyższym." #: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx msgid "Quick Actions" @@ -9278,7 +9282,7 @@ msgstr "Przekierowywanie do {0}..." #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Redirecting to your identity provider" -msgstr "" +msgstr "Przekierowywanie do dostawcy tożsamości" #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx @@ -9650,7 +9654,7 @@ msgstr "Zapewniamy, że Twój dokument jest poufny i nigdy nie zostanie udostęp #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Restrict sign-ins by email domain and choose the default role" -msgstr "" +msgstr "Ogranicz logowanie do wybranych domen i przypisz domyślną rolę" #: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx msgid "Restricted Access" @@ -9814,7 +9818,7 @@ msgstr "Szukaj identyfikatora lub nazwy" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx msgid "Search by document title, recipient:123, team:123 or user:123" -msgstr "" +msgstr "Szukaj tytułu dokumentu, odbiorcy, zespołu lub użytkownika" #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Search by domain or organisation name" @@ -10112,7 +10116,7 @@ msgstr "Wybierz widoczność" #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx msgid "selected" -msgstr "" +msgstr "zaznaczono" #: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Selected documents will be permanently deleted" @@ -10170,7 +10174,7 @@ msgstr "Wyślij dokument" #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "Send documents from your own domain. Email domains are available on the Enterprise plan." -msgstr "" +msgstr "Wysyłaj dokumenty ze swojej domeny. Domeny są dostępne w planie Enterprise." #: packages/email/templates/confirm-team-email.tsx msgid "Send documents on behalf of the team using the email address" @@ -10182,7 +10186,7 @@ msgstr "Wyślij dokumenty do odbiorców natychmiast" #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "Send emails to recipients from your domain" -msgstr "" +msgstr "Wysyłaj wiadomości ze swojej domeny" #: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx msgid "Send Envelope" @@ -10232,11 +10236,11 @@ msgstr "Nadawca został zgłoszony" #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "Sending from app.documenso.com" -msgstr "" +msgstr "Wysyłanie z app.documenso.com" #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "Sending from your domain" -msgstr "" +msgstr "Wysyłanie z Twojej domeny" #: apps/remix/app/components/forms/forgot-password.tsx msgid "Sending Reset Email..." @@ -10260,7 +10264,7 @@ msgstr "Wysłano w tym okresie" #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx msgid "Separate branding per team" -msgstr "" +msgstr "Oddzielny branding dla zespołów" #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" @@ -10501,6 +10505,7 @@ msgid "Signature ID" msgstr "Identyfikator podpisu" #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx msgid "Signature is too small" msgstr "Podpis jest zbyt mały" @@ -10541,7 +10546,7 @@ msgstr "Podpisany" #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Signed in" -msgstr "" +msgstr "Zalogowano" #: apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx msgid "Signer" @@ -10609,7 +10614,7 @@ msgstr "Logowanie..." #: apps/remix/app/utils/toast-error-messages.ts msgid "Signing link expired" -msgstr "" +msgstr "Link do podpisywania wygasł" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx @@ -10665,7 +10670,7 @@ msgstr "Od {0}" #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Single sign-on" -msgstr "" +msgstr "Logowanie SSO" #: apps/remix/app/components/general/admin-site-banner-section.tsx msgid "Site Banner" @@ -10688,7 +10693,7 @@ msgstr "Niektóre pola są umieszczone jedno nad drugim. Może to utrudnić podp #: apps/remix/app/utils/toast-error-messages.ts msgid "Some fields were not saved" -msgstr "" +msgstr "Niektóre pola nie zostały zapisane" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Some searches failed — results may be incomplete." @@ -11605,7 +11610,7 @@ msgstr "Dokument został odrzucony w zewnętrznej usłudze w imieniu odbiorcy" #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "The document will be hidden from your account" -msgstr "Dokument zostanie ukryty w Twoim koncie" +msgstr "Dokument zostanie ukryty na Twoim koncie" #: apps/remix/app/components/dialogs/template-use-dialog.tsx msgid "The document will be immediately sent to recipients if this is checked." @@ -11697,6 +11702,14 @@ msgstr "Brak pola podpisu dla następujących podpisujących:" msgid "The following team has been deleted. You will no longer be able to access this team and its documents" msgstr "Następujący zespół został usunięty. Nie będziesz mógł już uzyskać dostępu do tego zespołu i jego dokumentów" +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +msgid "The image could not be loaded. Please try again." +msgstr "Nie można załadować grafiki. Spróbuj ponownie." + +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +msgid "The image must be smaller than 5MB." +msgstr "Maksymalny rozmiar grafiki to 5 MB." + #: apps/remix/app/components/general/admin-license-card.tsx msgid "The license key has been copied to your clipboard" msgstr "Klucz licencyjny został skopiowany do schowka" @@ -12027,7 +12040,7 @@ msgstr "Szablon bezpośredniego linku nie może zostać użyty, ponieważ co naj #: apps/remix/app/utils/toast-error-messages.ts msgid "This document can no longer be signed. It may have been removed by the sender, or your signing access may have been revoked. Please contact the sender for a new signing link." -msgstr "" +msgstr "Nie można podpisać dokumentu. Być może został on usunięty przez nadawcę lub Twoje uprawnienia do podpisywania zostały unieważnione. Skontaktuj się z nadawcą, aby uzyskać nowy link do podpisywania." #: packages/email/template-components/template-document-super-delete.tsx msgid "This document can not be recovered, if you would like to dispute the reason for future documents please contact support." @@ -12059,7 +12072,7 @@ msgstr "Nie można zapisać dokumentu jako szablonu. Spróbuj ponownie." #: apps/remix/app/utils/toast-error-messages.ts msgid "This document has already been completed and no further signatures can be added." -msgstr "" +msgstr "Dokument został już zakończony i nie wymaga kolejnych podpisów." #: apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx #: packages/ui/primitives/recipient-selector.tsx @@ -12081,7 +12094,7 @@ msgstr "Dokument został anulowany przez właściciela." #: apps/remix/app/utils/toast-error-messages.ts msgid "This document has been cancelled by the sender and can no longer be signed. Please contact the sender if you believe this is a mistake." -msgstr "" +msgstr "Dokument został anulowany przez nadawcę i nie można go już podpisać. Jeśli uważasz to za błąd, skontaktuj się z nadawcą." #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "This document has been rejected by a recipient" @@ -12089,7 +12102,7 @@ msgstr "Dokument został odrzucony przez odbiorcę" #: apps/remix/app/utils/toast-error-messages.ts msgid "This document has been rejected by a recipient and can no longer be signed." -msgstr "" +msgstr "Dokument został odrzucony przez odbiorcę i nie można go już podpisać." #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "This document has been signed by all recipients" @@ -12097,7 +12110,7 @@ msgstr "Dokument został podpisany przez wszystkich odbiorców" #: apps/remix/app/utils/toast-error-messages.ts msgid "This document has not been sent for signing yet. Please wait for the sender to send it before signing." -msgstr "" +msgstr "Dokument nie został jeszcze wysłany do podpisania. Poczekaj na wysłanie go przez nadawcę." #: apps/remix/app/utils/toast-error-messages.ts msgid "This document has too many recipients. Please remove some recipients or contact support if you need more." @@ -12113,7 +12126,7 @@ msgstr "Dokument jest szkicem i nie został wysłany" #: apps/remix/app/utils/toast-error-messages.ts msgid "This document is signed in a set order and other recipients must sign before you. You will receive an email when it is your turn." -msgstr "" +msgstr "Dokument jest podpisywany zgodnie z ustaloną kolejnością. Gdy nadejdzie Twoja kolej, otrzymasz wiadomość." #: apps/remix/app/components/general/legacy-field-warning-popover.tsx msgid "This document is using legacy field insertion, we recommend using the new field insertion method for more accurate results." @@ -12192,6 +12205,10 @@ msgstr "Typ pliku nie jest obsługiwany. Prześlij dokument PDF lub Word." msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder." msgstr "Folder zawiera wiele elementów. Usunięcie go spowoduje przeniesienie wszystkich dokumentów i szablonów znajdujących się w tym folderze do folderu głównego." +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +msgid "This image is invalid, please upload a valid image file." +msgstr "Grafika jest nieprawidłowa. Prześlij prawidłowy plik." + #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: packages/ui/primitives/template-flow/add-template-settings.tsx msgid "This is how the document will reach the recipients once the document is ready for signing." @@ -12774,6 +12791,10 @@ msgstr "Nie można się zalogować" msgid "Unable to start the signing flow" msgstr "Nie można rozpocząć procesu podpisywania" +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +msgid "Unable to upload image" +msgstr "Nie można przesłać grafiki" + #: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx #: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx #: apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx @@ -12788,6 +12809,7 @@ msgstr "Niezakończono" #: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx +#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "Cofnij" @@ -12832,15 +12854,15 @@ msgstr "Rozłącz" #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx msgid "Unlock Branding Preferences" -msgstr "" +msgstr "Odblokuj ustawienia brandingu" #: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx msgid "Unlock Email Domains" -msgstr "" +msgstr "Odblokuj domeny" #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Unlock the Organisation SSO Portal" -msgstr "" +msgstr "Odblokuj logowanie SSO organizacji" #: apps/remix/app/components/general/folder/folder-card.tsx msgid "Unpin" @@ -13017,7 +13039,7 @@ msgstr "Ulepsz organizację <0>{0} do planu {planName}" #: apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx msgid "Upgrade Plan" -msgstr "" +msgstr "Ulepsz plan" #: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx msgid "Upgrade your plan to upload more documents" @@ -13078,6 +13100,10 @@ msgstr "Prześlij dokumenty i dodaj odbiorców" msgid "Upload failed" msgstr "Przesyłanie nie powiodło się" +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +msgid "Upload New Image" +msgstr "Prześlij nową grafikę" + #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Upload signature" msgstr "Podpis przesyłany" @@ -13506,7 +13532,7 @@ msgstr "Oczekiwanie na innych" #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing" -msgstr "" +msgstr "Oczekiwanie na zakończenie podpisywania przez innych" #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Waiting for others to sign" @@ -14004,7 +14030,7 @@ msgstr "Witaj z powrotem! Oto podsumowanie Twojego konta." #. placeholder {0}: organisation.name #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Welcome to {0}" -msgstr "" +msgstr "Witaj w organizacji {0}" #: apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx msgid "Welcome to {organisationName}" @@ -14085,7 +14111,7 @@ msgstr "W ramach limitu" #: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx msgid "Works with any OIDC provider — Okta, Entra ID, Google and more" -msgstr "" +msgstr "Działa z dowolnym dostawcą OIDC: Okta, Entra ID, Google i więcej" #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" @@ -15102,7 +15128,7 @@ msgstr "Koperta została ponownie wysłana." #: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx msgid "Your logo on signing pages and emails" -msgstr "" +msgstr "Twoje logo na stronach podpisywania i w wiadomościach" #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx msgid "Your Name" @@ -15239,7 +15265,7 @@ msgstr "Certyfikat podpisu jest nieważny, wygasł lub brakuje w nim wymaganego #: apps/remix/app/utils/toast-error-messages.ts msgid "Your signing link has expired. Please contact the sender to request a new one." -msgstr "" +msgstr "Twój link do podpisywania wygasł. Skontaktuj się z nadawcą, aby otrzymać nowy link." #: packages/lib/utils/document-audit-logs.ts msgid "Your signing provider authentication failed" @@ -15322,3 +15348,14 @@ msgstr "Twój kod weryfikacyjny:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "twoja-domena.pl inna-domena.pl" + +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +msgid "Zoom in" +msgstr "Powiększ" + +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx +msgid "Zoom out" +msgstr "Pomniejsz" + diff --git a/packages/lib/universal/fire-and-forget.ts b/packages/lib/universal/fire-and-forget.ts new file mode 100644 index 000000000..cdc46839b --- /dev/null +++ b/packages/lib/universal/fire-and-forget.ts @@ -0,0 +1,14 @@ +/** + * Run a function without awaiting it or letting it affect the caller. + * + * Both synchronous throws and promise rejections are swallowed, making this + * safe to call from request handlers and loaders for side work such as + * analytics that must never block or fail the response. + */ +export const fireAndForget = (fn: () => unknown) => { + try { + void Promise.resolve(fn()).catch(() => null); + } catch { + // Swallow synchronous errors. + } +}; diff --git a/packages/lib/utils/render-custom-email-template.test.ts b/packages/lib/utils/render-custom-email-template.test.ts new file mode 100644 index 000000000..b1bd4d2c7 --- /dev/null +++ b/packages/lib/utils/render-custom-email-template.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { renderCustomEmailTemplate } from './render-custom-email-template'; + +describe('renderCustomEmailTemplate', () => { + it('replaces a single variable', () => { + expect(renderCustomEmailTemplate('Hi {name}', { name: 'Sam' })).toBe('Hi Sam'); + }); + + it('replaces multiple variables separated by whitespace', () => { + expect( + renderCustomEmailTemplate('Hi {name}, sign at {url}', { name: 'Sam', url: 'https://x' }), + ).toBe('Hi Sam, sign at https://x'); + }); + + it('replaces adjacent variables and variables separated by a non-whitespace character', () => { + expect( + renderCustomEmailTemplate('{day}/{month}/{year}', { day: '01', month: '02', year: '2026' }), + ).toBe('01/02/2026'); + + expect( + renderCustomEmailTemplate('{firstName}-{lastName}', { + firstName: 'Ada', + lastName: 'Lovelace', + }), + ).toBe('Ada-Lovelace'); + }); + + it('leaves the key in place when the variable is not provided', () => { + expect(renderCustomEmailTemplate('Unknown {missing} here', {})).toBe('Unknown missing here'); + }); +}); diff --git a/packages/lib/utils/render-custom-email-template.ts b/packages/lib/utils/render-custom-email-template.ts index dbe7ae621..c1201bbd5 100644 --- a/packages/lib/utils/render-custom-email-template.ts +++ b/packages/lib/utils/render-custom-email-template.ts @@ -1,5 +1,9 @@ export const renderCustomEmailTemplate = >(template: string, variables: T): string => { - return template.replace(/\{(\S+)\}/g, (_, key) => { + // Match a run of non-brace, non-whitespace characters between braces. Using + // `\S+` here was greedy across braces, so placeholders separated by a + // non-whitespace character (e.g. "{day}/{month}/{year}") were captured as a + // single bogus key and left unrendered. + return template.replace(/\{([^\s{}]+)\}/g, (_, key) => { if (key in variables) { return variables[key]; } diff --git a/packages/trpc/server/api-token-router/create-api-token.ts b/packages/trpc/server/api-token-router/create-api-token.ts index a01389f48..b48322478 100644 --- a/packages/trpc/server/api-token-router/create-api-token.ts +++ b/packages/trpc/server/api-token-router/create-api-token.ts @@ -1,4 +1,7 @@ +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; +import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; import { ZCreateApiTokenRequestSchema, ZCreateApiTokenResponseSchema } from './create-api-token.types'; @@ -15,10 +18,26 @@ export const createApiTokenRoute = authenticatedProcedure }, }); - return await createApiToken({ + const createdToken = await createApiToken({ userId: ctx.user.id, teamId, tokenName, expiresIn: expirationDate, }); + + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { id: teamId }, + select: { organisationId: true }, + }); + + captureServerEvent({ + event: 'App: API Token Created', + userId: ctx.user.id, + teamId, + organisationId: team?.organisationId, + }); + }); + + return createdToken; }); diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 99d4de784..e11e1f25d 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -1,6 +1,7 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { convertToPdf } from '@documenso/lib/server-only/document-conversion'; @@ -21,12 +22,14 @@ import { findTemplates } from '@documenso/lib/server-only/template/find-template import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link'; +import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapFieldToLegacyField } from '@documenso/lib/utils/fields'; import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients'; import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates'; +import { prisma } from '@documenso/prisma'; import type { Envelope } from '@prisma/client'; import { DocumentDataType, EnvelopeType } from '@prisma/client'; @@ -744,7 +747,7 @@ export const templateRouter = router({ }); } - return await createTemplateDirectLink({ + const directLink = await createTemplateDirectLink({ userId, teamId, id: { @@ -753,6 +756,25 @@ export const templateRouter = router({ }, directRecipientId, }); + + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { id: template.teamId }, + select: { organisationId: true }, + }); + + captureServerEvent({ + event: 'App: Template Direct Link Enabled', + userId, + teamId: template.teamId, + organisationId: team?.organisationId, + properties: { + envelopeId: template.envelopeId, + }, + }); + }); + + return directLink; }), /** diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index 47fd237b4..423a39e1b 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -1,9 +1,12 @@ +import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event'; import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook'; import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-webhook-by-id'; import { editWebhook } from '@documenso/lib/server-only/webhooks/edit-webhook'; import { getWebhookById } from '@documenso/lib/server-only/webhooks/get-webhook-by-id'; import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-team-id'; import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook'; +import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; +import { prisma } from '@documenso/prisma'; import { authenticatedProcedure, router } from '../trpc'; import { findWebhookCallsRoute } from './find-webhook-calls'; @@ -51,7 +54,7 @@ export const webhookRouter = router({ createWebhook: authenticatedProcedure.input(ZCreateWebhookRequestSchema).mutation(async ({ input, ctx }) => { const { enabled, eventTriggers, secret, webhookUrl } = input; - return await createWebhook({ + const webhook = await createWebhook({ enabled, secret, webhookUrl, @@ -59,6 +62,25 @@ export const webhookRouter = router({ teamId: ctx.teamId, userId: ctx.user.id, }); + + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { id: ctx.teamId }, + select: { organisationId: true }, + }); + + captureServerEvent({ + event: 'App: Webhook Created', + userId: ctx.user.id, + teamId: ctx.teamId, + organisationId: team?.organisationId, + properties: { + triggerCount: eventTriggers.length, + }, + }); + }); + + return webhook; }), deleteWebhook: authenticatedProcedure.input(ZDeleteWebhookRequestSchema).mutation(async ({ input, ctx }) => {